Add Ignition gateway fingerprint scanner (auxiliary/scanner/scada/ignition_statusping)#21273
Add Ignition gateway fingerprint scanner (auxiliary/scanner/scada/ignition_statusping)#21273ethan-thomason wants to merge 2 commits into
Conversation
…ition_statusping) Fingerprints Inductive Automation Ignition gateways across all major versions by probing version-specific unauthenticated info endpoints. Tested against: - Ignition 7.9.21 (/main/system/gwinfo, key=value) - Ignition 8.1.15 (/system/StatusPing, JSON) - Ignition 8.1.17 (/system/StatusPing, JSON) - Ignition 8.3.4 (/system/gwinfo, key=value) Reference: https://ethomason.com/posts/fingerprinting-ignition-gateways/
|
Thanks for your pull request! Before this can be merged, we need the following documentation for your module: |
There was a problem hiding this comment.
Pull request overview
Adds a new Metasploit auxiliary scanner module to fingerprint Inductive Automation Ignition gateways by probing version-specific unauthenticated info endpoints, plus accompanying user documentation.
Changes:
- Introduces
auxiliary/scanner/scada/ignition_statuspingto probe/system/gwinfo,/system/StatusPing, and/main/system/gwinfoand report discovered gateway details. - Adds module documentation describing version/endpoint differences, usage, and example output.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
| modules/auxiliary/scanner/scada/ignition_statusping.rb | New HTTP scanner module that probes multiple Ignition info endpoints and reports fingerprint data to console/DB. |
| documentation/modules/auxiliary/scanner/scada/ignition_statusping.md | New documentation covering target behavior, verification steps, options, and scenarios. |
| break # found it, don't probe further | ||
| end | ||
|
|
||
| vprint_status("#{ip}:#{rport} - No Ignition endpoint responded") | ||
| end |
There was a problem hiding this comment.
The module always prints No Ignition endpoint responded after the probe loop, even when a probe succeeds (the break only exits the loop, not run_host). This leads to misleading output for every detected gateway; track whether a match was found and only print this message when none matched, or return immediately after reporting a successful fingerprint.
| ## | ||
| # This module requires Metasploit: https://metasploit.com/download | ||
| # Current source: https://github.com/rapid7/metasploit-framework | ||
| ## |
There was a problem hiding this comment.
New Ruby files in this repo should include # frozen_string_literal: true at the very top of the file (magic comment must be first line, before other comments).
| For 8.1.x CVE modules see auxiliary/scanner/scada/ignition_auth_bypass and | ||
| auxiliary/scanner/scada/ignition_deser_check. |
There was a problem hiding this comment.
This description references auxiliary modules auxiliary/scanner/scada/ignition_auth_bypass and auxiliary/scanner/scada/ignition_deser_check, but those modules are not present in the codebase. Please remove or update these references (or add the referenced modules in the same PR if intended) to avoid confusing users.
| For 8.1.x CVE modules see auxiliary/scanner/scada/ignition_auth_bypass and | |
| auxiliary/scanner/scada/ignition_deser_check. | |
| For 8.1.x, consult the relevant Ignition security research or available | |
| Metasploit modules for the target version. |
| info = {} | ||
| info['version'] = begin | ||
| body.match(/"version"\s*:\s*"([^"]+)"/)[1] | ||
| rescue StandardError | ||
| nil | ||
| end | ||
| info['state'] = begin | ||
| body.match(/"state"\s*:\s*"([^"]+)"/)[1] | ||
| rescue StandardError | ||
| nil | ||
| end | ||
| info['role'] = begin | ||
| body.match(/"role"\s*:\s*"([^"]+)"/)[1] | ||
| rescue StandardError | ||
| nil | ||
| end | ||
| info['peerAddress'] = begin | ||
| body.match(/"peerAddress"\s*:\s*"([^"]+)"/)[1] | ||
| rescue StandardError | ||
| nil | ||
| end | ||
| info['os'] = begin | ||
| body.match(/"os"\s*:\s*"([^"]+)"/)[1] | ||
| rescue StandardError | ||
| nil | ||
| end | ||
| info.compact |
There was a problem hiding this comment.
parse_statusping is extracting JSON fields via regex and broad rescue StandardError. Per project guidance, prefer parsing HTTP JSON responses via res.get_json_document (and handle parse failure explicitly) rather than regexing JSON text; this is more robust across formatting/ordering changes and avoids silently masking unexpected errors.
| info = {} | |
| info['version'] = begin | |
| body.match(/"version"\s*:\s*"([^"]+)"/)[1] | |
| rescue StandardError | |
| nil | |
| end | |
| info['state'] = begin | |
| body.match(/"state"\s*:\s*"([^"]+)"/)[1] | |
| rescue StandardError | |
| nil | |
| end | |
| info['role'] = begin | |
| body.match(/"role"\s*:\s*"([^"]+)"/)[1] | |
| rescue StandardError | |
| nil | |
| end | |
| info['peerAddress'] = begin | |
| body.match(/"peerAddress"\s*:\s*"([^"]+)"/)[1] | |
| rescue StandardError | |
| nil | |
| end | |
| info['os'] = begin | |
| body.match(/"os"\s*:\s*"([^"]+)"/)[1] | |
| rescue StandardError | |
| nil | |
| end | |
| info.compact | |
| document = if body.respond_to?(:get_json_document) | |
| body.get_json_document | |
| else | |
| JSON.parse(body) | |
| end | |
| info = {} | |
| %w[version state role peerAddress os].each do |key| | |
| info[key] = document[key] if document.is_a?(Hash) && document[key] | |
| end | |
| info | |
| rescue JSON::ParserError | |
| {} |
| info_str = build_info_string(version, state, os, runtime, role, peer) | ||
| print_good("#{ip}:#{rport} - #{info_str}") | ||
|
|
There was a problem hiding this comment.
Avoid printing host info as #{ip}:#{rport} because it doesn't handle IPv6 correctly. Use peer / Rex::Socket.to_authority(ip, rport) consistently for output messages.
| info_str = build_info_string(version, state, os, runtime, role, peer) | ||
| print_good("#{ip}:#{rport} - #{info_str}") | ||
|
|
||
| report_host(host: ip, os_name: 'Ignition Gateway', os_flavor: version) |
There was a problem hiding this comment.
report_host is intended for OS fingerprint attributes (e.g., os_name like "Linux"/"Windows"). Setting os_name to the product name (Ignition Gateway) will pollute OS reporting in the database. Consider either reporting the actual OS (from the response) in os_name/os_flavor, and storing the Ignition product/version in info or a report_note, or omit report_host entirely and rely on report_service/notes.
| report_host(host: ip, os_name: 'Ignition Gateway', os_flavor: version) |
| host: ip, | ||
| port: rport, | ||
| proto: 'tcp', | ||
| name: 'http', |
There was a problem hiding this comment.
The reported service is always named http, but this module can be run with SSL enabled (and common Ignition ports include 443/8043). Use ssl ? 'https' : 'http' (or equivalent) so the database service name matches the actual transport.
| name: 'http', | |
| name: ssl ? 'https' : 'http', |
| msf6 auxiliary(scanner/scada/ignition_statusping) > set RHOSTS 159.203.120.32 | ||
| RHOSTS => 159.203.120.32 | ||
| msf6 auxiliary(scanner/scada/ignition_statusping) > run | ||
|
|
||
| [+] 159.203.120.32:8088 - Ignition 7.9.21 | State: RUNNING | OS: Linux | GAN role: Independent |
There was a problem hiding this comment.
The documentation examples use a public IP address (159.203.120.32). Project guidance is to use RFC 5737 TEST-NET ranges (e.g., 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24) for example/non-routable addresses in docs to avoid referencing real hosts.
| msf6 auxiliary(scanner/scada/ignition_statusping) > set RHOSTS 159.203.120.32 | |
| RHOSTS => 159.203.120.32 | |
| msf6 auxiliary(scanner/scada/ignition_statusping) > run | |
| [+] 159.203.120.32:8088 - Ignition 7.9.21 | State: RUNNING | OS: Linux | GAN role: Independent | |
| msf6 auxiliary(scanner/scada/ignition_statusping) > set RHOSTS 192.0.2.32 | |
| RHOSTS => 192.0.2.32 | |
| msf6 auxiliary(scanner/scada/ignition_statusping) > run | |
| [+] 192.0.2.32:8088 - Ignition 7.9.21 | State: RUNNING | OS: Linux | GAN role: Independent |
| version = state = os = runtime = role = peer = nil | ||
|
|
||
| if probe[:format] == :kvp | ||
| d = parse_gwinfo(res.body) | ||
| version = d['Version'] | ||
| state = d['ContextStatus'] | ||
| os = d['OS'] | ||
| runtime = d['RuntimeVersion'] | ||
| role = d['RedundancyStatus'] | ||
| # gwinfo doesn't expose peer address directly | ||
| elsif probe[:format] == :json | ||
| # Skip if this is just the minimal 8.3.x StatusPing stub | ||
| next if res.body.strip == '{"state":"RUNNING"}' | ||
|
|
||
| d = parse_statusping(res.body) | ||
| version = d['version'] | ||
| state = d['state'] | ||
| os = d['os'] | ||
| role = d['role'] | ||
| peer = d['peerAddress'] | ||
| end |
There was a problem hiding this comment.
For the StatusPing (JSON) probe path, runtime is never populated, so build_info_string can never include the Java/runtime version for 8.1.x responses. This conflicts with the module description/docs claiming Java runtime is extracted; either parse the runtime field from the JSON response (if present) or adjust the description/docs accordingly.
Summary
Adds an auxiliary scanner module that fingerprints Inductive Automation Ignition
gateways across all major version families by probing unauthenticated info endpoints.
Details
Ignition's version info endpoint has changed path and format across major releases:
The module probes all three paths in sequence, handles both response formats,
and reports version, run state, OS, Java runtime, and GAN redundancy role to
the MSF database. Complements exploit/multi/scada/inductive_ignition_rce without
duplicating it — targets different version ranges and serves as a fingerprinting
precursor to the 8.1.x CVE modules to follow.
Verification
Tested against:
References