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

require 'msf/base/sessions/modem'
require 'msf/base/sessions/modem/quectel'

class MetasploitModule < Msf::Auxiliary
  DEFAULT_MAX_CHUNK_SIZE = 1024 # bytes per QISEND chunk
  DEFAULT_PROMPT_TIMEOUT_MS = 5000 # wait for '>' after QISEND (ms)
  DEFAULT_ACK_TIMEOUT_MS = 8000 # wait for SEND OK/FAIL (ms)
  DEFAULT_OPEN_TIMEOUT_MS = 30000 # wait for +QIOPEN URC (ms)
  DEFAULT_CMD_TIMEOUT_MS = 6000 # wait for AT command OK/ERROR (ms)

  # === v0.06.4 modem health / readiness defaults ===============================
  DEFAULT_STARTUP_OK_TIMEOUT_S = 0 # total seconds to wait for first AT OK (0 = no timeout)
  DEFAULT_STARTUP_OK_INTERVAL_MS = 1000  # delay between AT probes at startup (ms)
  DEFAULT_HEALTHCHECK_INTERVAL_S = 3     # seconds between AT probes at runtime
  DEFAULT_HEALTHCHECK_TIMEOUT_MS = 2000  # per-probe AT timeout (ms)
  DEFAULT_HEALTHCHECK_MAX_FAILS = 3 # consecutive failures before marking NOT READY

  DEFAULT_MODEM_SOCKETS = 12 # SID pool size (0..N-1); Quectel Cell Module supports up to 12

  # === Metasploit module proper ============================================

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Quectel Cellular Modem Pivot (Serial AT)',
        'Description' => %q{
          Opens a serial connection to a Quectel cellular modem and registers it as a 'modem' session capable of network
          pivoting. The Quectel modems have a limited number of sockets available, configurable using MODEM_SOCKETS. Once
          the session is established, it can be routed through using the `route` command.
        },
        'Author' => [
          'Deral Heiland',       # original SOCKS5 proxy module
          'Spencer McIntyre'     # native session refactor
        ],
        'License' => MSF_LICENSE,
        'Notes' => { 'Stability' => [], 'Reliability' => [], 'SideEffects' => [] }
      )
    )

    register_options(
      [
        OptString.new('SERIAL', [ true, 'Serial device for Quectel modem', '/dev/ttyUSB0' ]),
        OptInt.new('BAUD', [ true, 'Serial baud rate', 115200 ]),

        # Advanced performance knobs (tune as needed)
        OptInt.new('MODEM_SOCKETS', [ true, 'Number of Quectel socket IDs (SID pool size)', DEFAULT_MODEM_SOCKETS ]),
        OptInt.new('MAX_CHUNK_SIZE', [ true, 'Bytes per AT+QISEND chunk', DEFAULT_MAX_CHUNK_SIZE ]),
        OptInt.new('PROMPT_TIMEOUT_MS', [ true, "Timeout waiting for QISEND '>' prompt (ms)", DEFAULT_PROMPT_TIMEOUT_MS ]),
        OptInt.new('ACK_TIMEOUT_MS', [ true, 'Timeout waiting for SEND OK/FAIL (ms)', DEFAULT_ACK_TIMEOUT_MS ]),
        OptInt.new('OPEN_TIMEOUT_MS', [ true, 'Timeout waiting for +QIOPEN URC (ms)', DEFAULT_OPEN_TIMEOUT_MS ]),
        OptInt.new('CMD_TIMEOUT_MS', [ true, 'Timeout waiting for AT command OK/ERROR (ms)', DEFAULT_CMD_TIMEOUT_MS ]),

        # modem readiness / health watchdog
        OptInt.new('STARTUP_OK_TIMEOUT_S', [ true, 'Startup: total seconds to wait for first AT OK (0 = no timeout)', DEFAULT_STARTUP_OK_TIMEOUT_S ]),
        OptInt.new('STARTUP_OK_INTERVAL_MS', [ true, 'Startup: delay between AT probes (ms)', DEFAULT_STARTUP_OK_INTERVAL_MS ]),
        OptInt.new('HEALTHCHECK_INTERVAL_S', [ true, 'Runtime: seconds between modem AT health probes', DEFAULT_HEALTHCHECK_INTERVAL_S ]),
        OptInt.new('HEALTHCHECK_TIMEOUT_MS', [ true, 'Runtime: AT health probe timeout (ms)', DEFAULT_HEALTHCHECK_TIMEOUT_MS ]),
        OptInt.new('HEALTHCHECK_MAX_FAILS', [ true, 'Runtime: consecutive AT probe failures before marking modem NOT READY', DEFAULT_HEALTHCHECK_MAX_FAILS ])
      ]
    )

    @modem = nil
    @cfg = {}
    @session_registered = false
  end

  def setup
    super
    unless Msf::Sessions::Modem::Quectel::Driver.supported_platform?
      fail_with(Failure::BadConfig,
                "This module uses Linux-specific termios ioctls and cannot run on #{RUBY_PLATFORM}")
    end

    dev = datastore['SERIAL']
    baud = datastore['BAUD'].to_i

    # Build runtime config from datastore (keep everything in seconds/bytes internally)
    @cfg = {
      modem_sockets: [datastore['MODEM_SOCKETS'].to_i, 1].max,
      max_chunk_size: [datastore['MAX_CHUNK_SIZE'].to_i, 1].max,
      prompt_timeout: [datastore['PROMPT_TIMEOUT_MS'].to_i, 0].max / 1000.0,
      ack_timeout: [datastore['ACK_TIMEOUT_MS'].to_i, 0].max / 1000.0,
      open_timeout: [datastore['OPEN_TIMEOUT_MS'].to_i, 0].max / 1000.0,
      cmd_timeout: [datastore['CMD_TIMEOUT_MS'].to_i, 0].max / 1000.0,
      startup_ok_timeout: [datastore['STARTUP_OK_TIMEOUT_S'].to_i, 0].max,
      startup_ok_interval: [datastore['STARTUP_OK_INTERVAL_MS'].to_i, 0].max / 1000.0,
      healthcheck_interval: [datastore['HEALTHCHECK_INTERVAL_S'].to_i, 0].max,
      healthcheck_timeout: [datastore['HEALTHCHECK_TIMEOUT_MS'].to_i, 0].max / 1000.0,
      healthcheck_max_fails: [datastore['HEALTHCHECK_MAX_FAILS'].to_i, 1].max
    }

    # Open the serial port and start the background reader thread.
    begin
      @modem = Msf::Sessions::Modem::Quectel::Driver.new(dev, baud, framework, @cfg)
    rescue ::Errno::ENOENT
      fail_with(Failure::BadConfig, "Serial device not found: #{dev}")
    rescue ::Errno::EACCES
      fail_with(Failure::BadConfig, "Permission denied opening #{dev} - check that your user is in the 'dialout' group")
    rescue ::Errno::EBUSY
      fail_with(Failure::BadConfig, "#{dev} is busy - another process may have it open")
    rescue ::Errno::EIO, ::Errno::ENXIO
      fail_with(Failure::Unreachable, "I/O error on #{dev} - check the USB cable and modem power")
    rescue ::Errno::ENOTTY
      fail_with(Failure::BadConfig, "#{dev} is not a serial port (ioctl TCGETS failed)")
    rescue ::StandardError => e
      fail_with(Failure::Unknown, "Failed to open serial port on #{dev}: #{e.class} #{e.message}")
    end

    # Poll AT until the modem is responsive.
    print_status('Probing modem with AT until OK...')
    begin
      @modem.startup_wait_for_ok(@cfg[:startup_ok_timeout], @cfg[:startup_ok_interval], @cfg[:healthcheck_timeout])
    rescue ::Rex::TimeoutError => e
      fail_with(Failure::TimeoutExpired, e.message)
    rescue ::StandardError => e
      fail_with(Failure::Unknown, "Modem startup probe failed: #{e.class} #{e.message}")
    end
    print_good('Modem is responding to AT commands.')

    # Best-effort wait for the RDY banner (already past it on a warm start).
    print_status('Waiting briefly for RDY URC (best-effort)...')
    @modem.wait_for_rdy(5)

    # Disable echo so URC parsing is not confused by command echoes.
    begin
      @modem.send_at('ATE0', @cfg[:cmd_timeout])
    rescue ::StandardError => e
      fail_with(Failure::Unknown, "Failed to disable echo (ATE0): #{e.class} #{e.message}")
    end

    # Close any leftover socket connections from a previous session (e.g. MSF
    # crashed without sending AT+QICLOSE). Errors are silently ignored - a SID
    # that is not open will return ERROR, which is expected.
    print_status('Closing any leftover modem socket connections...')
    (0...@cfg[:modem_sockets]).each do |sid|
      @modem.send_at("AT+QICLOSE=#{sid},0", @cfg[:cmd_timeout])
    rescue ::StandardError => e
      nil
    end

    # Start runtime health watchdog now that the modem is known-good.
    @modem.start_health_watchdog
  end

  def cleanup
    # Only close the modem if the session was never registered.
    # Once the session takes ownership, it is responsible for
    # closing the modem via Msf::Sessions::Modem::Quectel#cleanup.
    @modem.close if @modem && !@session_registered
    super
  end

  def run
    unless @modem.modem_ready?
      print_error('Modem is not ready - check serial connection and power')
      return
    end

    sess = Msf::Sessions::Modem::Quectel.new(@modem)
    sess.set_from_exploit(self)
    framework.sessions.register(sess)
    # Transfer modem ownership to the session; cleanup must not close it now.
    @session_registered = true
    print_good("Modem session #{sess.sid} opened (#{@cfg[:modem_sockets]} sockets, #{@cfg[:max_chunk_size]}B chunks)")
    # Return immediately - the session runs independently of this module job.
  end

end