Lucene search
K

Quectel Cellular Modem Pivot (Serial AT)

🗓️ 19 Jun 2026 19:03:20Reported by Deral Heiland, Spencer McIntyreType 
metasploit
 metasploit
🔗 www.rapid7.com👁 212 Views

Opens serial Quectel modem, registers a modem session for network pivoting; sockets limited by MODEM_SOCKETS.

Code
##
# 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

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation