Lucene search
K

GraphQL Introspection Scanner

🗓️ 15 Jul 2025 18:55:06Reported by sjanusz-r7Type 
metasploit
 metasploit
🔗 www.rapid7.com👁 550 Views

Queries a GraphQL endpoint to retrieve its schema via introspection when enabled.

Code
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##

class MetasploitModule < Msf::Auxiliary

  include Msf::Exploit::Remote::HttpClient
  include Msf::Auxiliary::Report

  prepend Msf::Exploit::Remote::AutoCheck

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'GraphQL Introspection Scanner',
        'Description' => %q{
          This module queries a GraphQL API Endpoint to retrieve schema data by using
          introspection, if it is enabled on the server. This module works on all GraphQL versions.
        },
        'License' => MSF_LICENSE,
        'Author' => [
          'sjanusz-r7', # Metasploit module
        ],
        'References' => [
          [ 'URL', 'https://portswigger.net/web-security/graphql' ],
          [ 'URL', 'https://graphql.org/learn/introspection/' ]
        ],
        'Notes' => {
          'Stability' => [CRASH_SAFE],
          'SideEffects' => [IOC_IN_LOGS],
          'Reliability' => []
        }
      )
    )
    register_options([
      OptString.new('TARGETURI', [true, 'Base path of the GraphQL endpoint', '/'])
    ])
  end

  # Values that can be matched against to verify that introspection is not enabled on the server.
  # @return [Array<Regex>] An array of regular expressions
  def introspection_not_enabled_values
    [ /introspection is not (allowed|enabled)/i, /the query contained __schema/i, /to enable introspection/i ]
  end

  # Check if the response received from the server suggests that introspection is enabled on the server, by comparing it
  # to a known good value.
  # @param response The response received from the server.
  # @return [TrueClass|FalseClass] True if the response matched a known introspection result, false otherwise.
  def responded_with_introspected_data?(response)
    return false if introspection_not_enabled_values.any? { |regex| response&.body.to_s.match?(regex) }

    # Known good response
    response&.body.to_s == "{\"data\":{\"__schema\":{\"queryType\":{\"name\":\"Query\"}}}}\n"
  end

  # Create a small query, used to test if introspection is enabledo n the GraphQL endpoint.
  # @return [String] The processed introspection probe query.
  def introspection_probe_query
    <<~EOF
      query {
        __schema {
          queryType {
            name
          }
        }
      }
    EOF
  end

  # Create a unique query that will try to dump the GraphQL schema.
  # This dumps the data definitions, objects etc. not the data stored on the server.
  # Original query comes from: https://portswigger.net/web-security/graphql
  # @return [String] The processed schema dump query
  def schema_dump_query
    # Obfuscate the variable names with the hopes it will not get picked up by any logging solutions as suspicious.
    vars_map = {
      input_fragment: Rex::Text.rand_text_alpha(8),
      type_fragment: Rex::Text.rand_text_alpha(8),
      type_reference: Rex::Text.rand_text_alpha(8)
    }

    # Fragments need to be present at the end, outside the curly braces of the 'query'
    <<~EOF
      query {
        __schema {
          queryType {
            name
          }
          mutationType {
            name
          }
          subscriptionType {
            name
          }
          types {
            ...#{vars_map[:type_fragment]}
          }
          directives {
            name
            description
            args {
              ...#{vars_map[:input_fragment]}
            }
          }
        }
      }
      fragment #{vars_map[:type_fragment]} on __Type {
        kind
        name
        description
        inputFields {
          ...#{vars_map[:input_fragment]}
        }
        fields(includeDeprecated: true) {
          name
          description
          isDeprecated
          deprecationReason
          args {
            ...#{vars_map[:input_fragment]}
          }
          type {
            ...#{vars_map[:type_reference]}
          }
        }
        inputFields {
          ...#{vars_map[:input_fragment]}
        }
        interfaces {
          ...#{vars_map[:type_reference]}
        }
        enumValues(includeDeprecated: true) {
          name
          description
          isDeprecated
          deprecationReason
        }
        possibleTypes {
          ...#{vars_map[:type_reference]}
        }
      }
      fragment #{vars_map[:input_fragment]} on __InputValue {
        name
        description
        defaultValue
        type {
          ...#{vars_map[:type_reference]}
        }
      }
      fragment #{vars_map[:type_reference]} on __Type {
        kind
        name
        ofType {
          kind
          name
          ofType {
            kind
            name
            ofType {
              kind
              name
            }
          }
        }
      }
    EOF
  end

  # Report a GraphQL instance on the current host and port.
  # @return [Mdm::Service] The reported service instance.
  def report_graphql_service
    report_service(
      host: rhost,
      port: rport,
      name: (ssl ? 'https' : 'http'),
      proto: 'tcp'
    )
  end

  # Report a GraphQL Introspection vulnerability on the current host and port.
  # @return [Mdm::Vuln] The reported vulnerability instance.
  def report_graphql_vuln
    report_vuln(
      {
        host: rhost,
        port: rport,
        name: 'GraphQL Information Disclosure through Introspection',
        refs: references
      }
    )
  end

  # Report a GraphQL Introspection web vulnerability on the current host and port.
  # @param service The GraphQL Mdm::Service instance.
  # @param query The query string used to check for the web vulnerability.
  # @param response The reponse from the server, used as proof that the vulnerability can be exploited.
  # @return [Mdm::WebVuln] The reported web vulnerability instance.
  def report_graphql_web_vuln(service, query, response)
    report_web_vuln(
      {
        host: rhost,
        port: rport,
        ssl: ssl,
        service: service,
        path: normalize_uri(target_uri.path),
        query: query,
        method: 'POST',
        params: [
          [ 'data', query ]
        ],
        pname: 'path',
        proof: response.body,
        name: 'GraphQL Introspection',
        description: 'GraphQL endpoint has enabled introspection. This can lead to information disclosure',
        owner: self,
        category: 'Information Disclosure',
        confidence: 100,
        risk: 4
      }
    )
  end

  # Send out a GraphQL request to the current endpoint, with the provided query string.
  # @param query The query string to execute.
  # @return (see Msf::Exploit::Remote::HttpClient#send_request_cgi)
  def send_graphql_request(query)
    send_request_cgi(
      'uri' => normalize_uri(target_uri.path),
      'method' => 'POST',
      'ctype' => 'application/json',
      'headers' => {
        'Accept' => 'application/json'
      },
      'data' => JSON.generate({ query: query })
    )
  end

  # Process the errors array into a nice human-readable and formatted string.
  # @param errors An array of errors.
  # @return [String, nil] A string with formatted error messages
  def process_errors(errors)
    return nil if errors&.empty?

    # APIs aren't consistent. Some have an error message, some have title & detail.
    # Match all the known cases so far, otherwise return the inspected value.

    errors.map do |error|
      "  - #{error['message'] || error['detail'] || error['description']}"
    end.join("\n") || ''
  end

  def valid_graphql_response?(hash)
    return false if hash.nil?

    hash.try(:[], 'data').try(:[], '__schema').try(:[], 'queryType').try(:[], 'name').is_a?(String)
  end

  # Check if the current endpoint is vulnerable to GraphQL Introspection information disclosure.
  # @return [Exploit::CheckCode]
  def check
    query = introspection_probe_query
    res = send_graphql_request(query)

    if res.nil?
      return Exploit::CheckCode::Unknown('The server did not send a response.')
    end

    case res.code
    when 200
      json = res.get_json_document

      # validate the JSON response has the expected structure of an introspection response, to avoid false positives.
      if valid_graphql_response?(json)
        graphql_service = report_graphql_service
        report_graphql_vuln
        report_graphql_web_vuln(graphql_service, query, res)

        return Exploit::CheckCode::Vulnerable('The server has introspection enabled.')
      else
        return Exploit::CheckCode::Safe('The server responded with a 200 status code, but the response did not have the expected structure of an introspection response.')
      end
    when 400
      json = res.get_json_document
      error_messages = process_errors(json['errors'] || Array.wrap(json['error']))

      if error_messages.nil?
        return Exploit::CheckCode::Safe('The server responded with an error status code, but did not provide any error messages in the response.')
      end

      safe_message = "The server responded with an error status code and the following error(s) to the introspection request:\n#{error_messages}"
      return Exploit::CheckCode::Safe(safe_message)
    when 403
      # Don't report the GraphQL service here, as this could be a generic 'No Access', so we are not sure if the GraphQL
      # endpoint exists or not.
      return Exploit::CheckCode::Unknown('The server did not allow access to the GraphQL endpoint.')
    when 422
      # Rails application missing a CSRF token would return 422, but we are not 100% sure if this is a GraphQL endpoint.
      return Exploit::CheckCode::Unknown('The server required a CSRF token.')
    else
      # We are not 100% sure that the service is a GraphQL endpoint. It could be a generic 403 Access Denied.
      return Exploit::CheckCode::Unknown("The server is online, but returned an unexpected response code: '#{res.code}'.")
    end
  end

  # Attempt a schema dump request against a GraphQL endpoint
  # @return [nil]
  def run
    query = schema_dump_query
    res = send_graphql_request(query)

    if res.nil?
      print_error("#{Rex::Socket.to_authority(rhost, rport)} - The server did not send a response.")
      return
    end

    if res.code == 200
      print_good("#{Rex::Socket.to_authority(rhost, rport)} - Server responded with introspected data. Reporting a vulnerability, and storing it as loot.")
      # validate the JSON response has the expected structure of an introspection response, to avoid false positives.
      unless valid_graphql_response?(res.get_json_document)
        print_error("#{Rex::Socket.to_authority(rhost, rport)} - Server responded with a 200 status code, but the response did not have the expected structure of an introspection response")
        return
      end
      graphql_service = report_graphql_service
      report_graphql_vuln
      report_graphql_web_vuln(graphql_service, query, res)
      store_loot('graphql.schema', 'json', rhost, res.body, 'graphql-schema.json', 'GraphQL Schema Dump', graphql_service)
    else
      json = res.get_json_document
      if json.nil?
        print_error("#{Rex::Socket.to_authority(rhost, rport)} - Server replied with an unexpected status code: '#{res.code}', and the response was not a valid JSON document.")
        return
      end

      if json.key?('errors') || json.key?('error')
        print_error("#{Rex::Socket.to_authority(rhost, rport)} - Server encountered the following error(s) (code: '#{res.code}'):\n#{process_errors(json['errors'] || Array.wrap(json['error']))}")
      else
        print_error("#{Rex::Socket.to_authority(rhost, rport)} - Server replied with an unexpected status code: '#{res.code}'")
      end
    end
  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

10 Jul 2026 19:01Current
5.5Medium risk
Vulners AI Score5.5
550