Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
## Vulnerable Application

Inductive Automation Ignition is a widely deployed SCADA platform used in critical
infrastructure worldwide. This module targets an unauthenticated information disclosure
present across all major Ignition versions — the gateway exposes version, run state,
OS, Java runtime, and GAN redundancy topology without any credentials.

Ignition can be downloaded from [inductiveautomation.com](https://inductiveautomation.com/downloads/ignition).
A free 2-hour trial license is available and resets on restart, which is sufficient
for testing purposes.

The endpoint and response format differ by version:

| Version | Endpoint | Format |
|---|---|---|
| 7.9.x | `/main/system/gwinfo` | semicolon-delimited key=value |
| 8.0.x | `/system/gwinfo` | semicolon-delimited key=value |
| 8.1.x | `/system/StatusPing` | JSON |
| 8.3.x | `/system/gwinfo` | semicolon-delimited key=value |

This module has been tested against the following Ignition versions on Linux:

* 7.9.21
* 8.1.15
* 8.1.17
* 8.3.4

8.0.x behavior is inferred from the source of the existing
`exploit/multi/scada/inductive_ignition_rce` module, which uses `/system/gwinfo`
for version detection prior to exploitation.

## Verification Steps

1. Install Ignition (any version 7.9+) and complete initial gateway commissioning
2. Start msfconsole
3. `use auxiliary/scanner/scada/ignition_statusping`
4. `set RHOSTS <target IP>`
5. `run`
6. The module should return gateway version, state, OS, Java runtime, and GAN role

## Options

### RHOSTS

The target host(s) or CIDR range to scan. Supports standard MSF RHOSTS syntax
including comma-separated IPs and CIDR notation (e.g. `10.10.0.0/24`).

### RPORT

The Ignition gateway HTTP port. Default: `8088`. Ignition can be configured to run
on alternate ports — common alternatives include `80`, `443`, `8043`.

## Scenarios

### Ignition 7.9.21 — Single host

```
msf6 > use auxiliary/scanner/scada/ignition_statusping
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
Comment on lines +59 to +63
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
[*] Scanned 1 of 1 hosts (100% complete)
[*] Auxiliary module execution completed
```

### Mixed version network scan — CIDR range

The following shows a scan across a /24 containing gateways at multiple versions,
including an 8.3.4 GAN redundancy pair (Master + Backup):

```
msf6 > use auxiliary/scanner/scada/ignition_statusping
msf6 auxiliary(scanner/scada/ignition_statusping) > set RHOSTS 10.10.0.0/24
RHOSTS => 10.10.0.0/24
msf6 auxiliary(scanner/scada/ignition_statusping) > run

[+] 10.10.0.3:8088 - Ignition 8.3.4 | State: RUNNING | OS: Linux | Java: 17.0.17 | GAN role: Master
[+] 10.10.0.4:8088 - Ignition 8.3.4 | State: RUNNING | OS: Linux | Java: 17.0.17 | GAN role: Backup
[+] 10.10.0.7:8088 - Ignition 7.9.21 | State: RUNNING | OS: Linux | GAN role: Independent
[+] 10.10.0.8:8088 - Ignition 8.1.15 | State: RUNNING | OS: Linux | Java: 11.0.14.1 | GAN role: Independent
[*] Scanned 256 of 256 hosts (100% complete)
[*] Auxiliary module execution completed
```

The GAN role output (Master/Backup) identifies redundancy pairs and reveals the network
topology of the Ignition deployment without authentication. This complements
`exploit/multi/scada/inductive_ignition_rce` (CVE-2020-10644), which targets 8.0.x only,
by extending fingerprinting coverage to 7.9.x, 8.1.x, and 8.3.x.
151 changes: 151 additions & 0 deletions modules/auxiliary/scanner/scada/ignition_statusping.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
##
# This module requires Metasploit: https://metasploit.com/download
# Current source: https://github.com/rapid7/metasploit-framework
##
Comment on lines +1 to +4
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

class MetasploitModule < Msf::Auxiliary
include Msf::Exploit::Remote::HttpClient
include Msf::Auxiliary::Scanner
include Msf::Auxiliary::Report

def initialize(info = {})
super(
update_info(
info,
'Name' => 'Inductive Automation Ignition Gateway Fingerprint',
'Description' => %q{
Fingerprints Inductive Automation Ignition gateways across all major versions
by probing version-specific info endpoints. Extracts version, run state, OS,
Java runtime, and GAN redundancy role without authentication.

Endpoint and format by version:
7.9.x — /main/system/gwinfo (key=value)
8.0.x — /system/gwinfo (key=value)
8.1.x — /system/StatusPing (JSON)
8.3.x — /system/gwinfo (key=value, includes RuntimeVersion/RequireSsl)

For 8.0.x exploitation see exploit/multi/scada/inductive_ignition_rce.
For 8.1.x CVE modules see auxiliary/scanner/scada/ignition_auth_bypass and
auxiliary/scanner/scada/ignition_deser_check.
Comment on lines +28 to +29
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

Copilot uses AI. Check for mistakes.
},
'Author' => ['Ethan Thomason <[email protected]>'],
'License' => MSF_LICENSE,
'References' => [
['URL', 'https://ethomason.com/posts/fingerprinting-ignition-gateways/'],
],
'Notes' => {
'Stability' => [CRASH_SAFE],
'SideEffects' => [IOC_IN_LOGS],
'Reliability' => []
}
)
)
register_options([Opt::RPORT(8088)])
end

# Parse key=value format used by 7.9.x, 8.0.x, 8.3.x
# e.g. ContextStatus=RUNNING;Version=8.3.4;OS=Linux;RuntimeVersion=17.0.17
def parse_gwinfo(body)
info = {}
body.split(';').each do |pair|
k, v = pair.split('=', 2)
info[k.strip] = v.to_s.strip if k
end
info
end

# Parse JSON format used by 8.1.x StatusPing
def parse_statusping(body)
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
Comment on lines +59 to +85
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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
{}

Copilot uses AI. Check for mistakes.
end

def build_info_string(version, state, os, runtime, role, peer)
parts = ["Ignition #{version}"]
parts << "State: #{state}" if state
parts << "OS: #{os}" if os
parts << "Java: #{runtime}" if runtime
parts << "GAN role: #{role}" if role
parts << "Peer: #{peer}" if peer && role && role !~ /independent/i
parts.join(' | ')
end

def run_host(ip)
# Probe order: gwinfo covers 7.9/8.0/8.3, StatusPing covers 8.1
probes = [
{ uri: '/system/gwinfo', format: :kvp },
{ uri: '/system/StatusPing', format: :json },
{ uri: '/main/system/gwinfo', format: :kvp },
]

probes.each do |probe|
res = send_request_cgi({ 'method' => 'GET', 'uri' => probe[:uri] })
next unless res && res.code == 200
next if res.body.strip.empty?

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
Comment on lines +111 to +131
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

next unless version

info_str = build_info_string(version, state, os, runtime, role, peer)
print_good("#{ip}:#{rport} - #{info_str}")

Comment on lines +135 to +137
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
report_host(host: ip, os_name: 'Ignition Gateway', os_flavor: version)
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
report_host(host: ip, os_name: 'Ignition Gateway', os_flavor: version)

Copilot uses AI. Check for mistakes.
report_service(
host: ip,
port: rport,
proto: 'tcp',
name: 'http',
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
name: 'http',
name: ssl ? 'https' : 'http',

Copilot uses AI. Check for mistakes.
info: info_str
)
break # found it, don't probe further
end

vprint_status("#{ip}:#{rport} - No Ignition endpoint responded")
end
Comment on lines +146 to +150
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
end
Loading