feat(ics): EtherNet/IP probe + OT service-discovery port scan (#175)
This commit was merged in pull request #175.
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
//! Minimal EtherNet/IP (CIP) reachability probe.
|
||||||
|
//!
|
||||||
|
//! Sends an EtherNet/IP encapsulation **ListIdentity** command (0x0063) over TCP
|
||||||
|
//! 44818 and checks for a valid encapsulation reply — confirming a CIP device
|
||||||
|
//! without opening a session or writing anything.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
/// Outcome of an EtherNet/IP handshake probe.
|
||||||
|
#[derive(Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct EnipProbe {
|
||||||
|
/// A TCP connection to the port was established.
|
||||||
|
pub reachable: bool,
|
||||||
|
/// The endpoint returned a valid EtherNet/IP encapsulation reply.
|
||||||
|
pub is_enip: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe an EtherNet/IP endpoint with a ListIdentity request. Read-only.
|
||||||
|
pub async fn probe(host: &str, port: u16, budget: Duration) -> EnipProbe {
|
||||||
|
let mut out = EnipProbe::default();
|
||||||
|
let Ok(Ok(mut stream)) = timeout(budget, TcpStream::connect((host, port))).await else {
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
out.reachable = true;
|
||||||
|
|
||||||
|
// Encapsulation header (24 bytes): command(2) length(2) session(4) status(4)
|
||||||
|
// context(8) options(4). ListIdentity = command 0x0063, everything else zero.
|
||||||
|
let mut req = vec![0u8; 24];
|
||||||
|
req[0..2].copy_from_slice(&0x0063u16.to_le_bytes());
|
||||||
|
if timeout(budget, stream.write_all(&req))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(Result::ok)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hdr = [0u8; 24];
|
||||||
|
if timeout(budget, stream.read_exact(&mut hdr))
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.and_then(Result::ok)
|
||||||
|
.is_none()
|
||||||
|
{
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
let command = u16::from_le_bytes([hdr[0], hdr[1]]);
|
||||||
|
let status = u32::from_le_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]);
|
||||||
|
// Echoed command + success status = a valid EtherNet/IP encapsulation reply.
|
||||||
|
if command == 0x0063 && status == 0 {
|
||||||
|
out.is_enip = true;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
async fn mock_server() -> std::net::SocketAddr {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||||
|
let addr = listener.local_addr().expect("addr");
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let (mut sock, _) = listener.accept().await.expect("accept");
|
||||||
|
let mut req = [0u8; 24];
|
||||||
|
if sock.read_exact(&mut req).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reply: echo command 0x0063, status 0, no data.
|
||||||
|
let mut hdr = vec![0u8; 24];
|
||||||
|
hdr[0..2].copy_from_slice(&0x0063u16.to_le_bytes());
|
||||||
|
let _ = sock.write_all(&hdr).await;
|
||||||
|
});
|
||||||
|
addr
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn probe_detects_an_ethernetip_device() {
|
||||||
|
let addr = mock_server().await;
|
||||||
|
let p = probe(&addr.ip().to_string(), addr.port(), Duration::from_secs(2)).await;
|
||||||
|
assert!(p.reachable && p.is_enip);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn probe_reports_unreachable_for_a_closed_port() {
|
||||||
|
let p = probe("127.0.0.1", 1, Duration::from_millis(500)).await;
|
||||||
|
assert!(!p.reachable && !p.is_enip);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,10 @@
|
|||||||
//! unauthenticated control interfaces. It is read-only: it never writes to a live
|
//! unauthenticated control interfaces. It is read-only: it never writes to a live
|
||||||
//! process. Modbus/TCP and OPC UA are implemented; EtherNet-IP is a follow-on.
|
//! process. Modbus/TCP and OPC UA are implemented; EtherNet-IP is a follow-on.
|
||||||
|
|
||||||
|
pub mod ethernetip;
|
||||||
pub mod modbus;
|
pub mod modbus;
|
||||||
pub mod opcua;
|
pub mod opcua;
|
||||||
|
pub mod portscan;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
@@ -14,17 +16,21 @@ use compliance_core::models::{Finding, ScanType, Severity};
|
|||||||
|
|
||||||
use crate::pipeline::dedup;
|
use crate::pipeline::dedup;
|
||||||
|
|
||||||
/// Default Modbus/TCP and OPC UA ports (each on its own well-known port,
|
/// Well-known deep-probe ports (each independent of any WebVisu HTTP port).
|
||||||
/// independent of any WebVisu HTTP port).
|
|
||||||
const MODBUS_PORT: u16 = 502;
|
const MODBUS_PORT: u16 = 502;
|
||||||
const OPCUA_PORT: u16 = 4840;
|
const OPCUA_PORT: u16 = 4840;
|
||||||
|
const ENIP_PORT: u16 = 44818;
|
||||||
|
|
||||||
/// Probe a PLC/SPS device's industrial-protocol surface (Modbus/TCP + OPC UA) and
|
/// Probe a PLC/SPS device's industrial-protocol surface and return findings.
|
||||||
/// return findings. Read-only. `endpoint` is the target's live-URL / host reference.
|
/// Read-only. Deep-probes Modbus/TCP, OPC UA and EtherNet/IP, plus a service
|
||||||
|
/// discovery scan of the remaining OT / insecure-management ports. `endpoint` is
|
||||||
|
/// the target's live-URL / host reference.
|
||||||
pub async fn probe_target(endpoint: &str, repo_id: &str, budget: Duration) -> Vec<Finding> {
|
pub async fn probe_target(endpoint: &str, repo_id: &str, budget: Duration) -> Vec<Finding> {
|
||||||
let (host, modbus_port) = parse_endpoint(endpoint);
|
let (host, modbus_port) = parse_endpoint(endpoint);
|
||||||
let mut findings = modbus_findings(&host, modbus_port, repo_id, budget).await;
|
let mut findings = modbus_findings(&host, modbus_port, repo_id, budget).await;
|
||||||
findings.extend(opcua_findings(&host, OPCUA_PORT, repo_id, budget).await);
|
findings.extend(opcua_findings(&host, OPCUA_PORT, repo_id, budget).await);
|
||||||
|
findings.extend(enip_findings(&host, ENIP_PORT, repo_id, budget).await);
|
||||||
|
findings.extend(portscan_findings(&host, repo_id, budget).await);
|
||||||
findings
|
findings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +143,88 @@ async fn opcua_findings(host: &str, port: u16, repo_id: &str, budget: Duration)
|
|||||||
findings
|
findings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Findings from probing the EtherNet/IP (CIP) surface (default port 44818).
|
||||||
|
async fn enip_findings(host: &str, port: u16, repo_id: &str, budget: Duration) -> Vec<Finding> {
|
||||||
|
let probe = ethernetip::probe(host, port, budget).await;
|
||||||
|
if !probe.is_enip {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let target = format!("{host}:{port}");
|
||||||
|
let fp = dedup::compute_fingerprint(&[repo_id, "ics-ethernetip-exposed", &target]);
|
||||||
|
let mut f = Finding::new(
|
||||||
|
repo_id.to_string(),
|
||||||
|
fp,
|
||||||
|
"ics-probe".to_string(),
|
||||||
|
ScanType::IcsProbe,
|
||||||
|
"EtherNet/IP (CIP) interface exposed on the network".to_string(),
|
||||||
|
format!(
|
||||||
|
"The device at {target} answers EtherNet/IP (CIP) requests. EtherNet/IP has no \
|
||||||
|
authentication in the base protocol, so a host that can reach it can enumerate \
|
||||||
|
and interact with the device's control objects."
|
||||||
|
),
|
||||||
|
Severity::High,
|
||||||
|
);
|
||||||
|
f.rule_id = Some("ics-ethernetip-exposed".to_string());
|
||||||
|
f.cwe = Some("CWE-306".to_string());
|
||||||
|
f.remediation = Some(
|
||||||
|
"Restrict EtherNet/IP (44818/2222) to a trusted control network; use CIP Security \
|
||||||
|
(encryption + authentication) on devices that support it."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
vec![f]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Findings from the service-discovery port scan of the remaining OT /
|
||||||
|
/// insecure-management surface.
|
||||||
|
async fn portscan_findings(host: &str, repo_id: &str, budget: Duration) -> Vec<Finding> {
|
||||||
|
let open = portscan::scan(host, portscan::KNOWN_PORTS, budget).await;
|
||||||
|
open.into_iter()
|
||||||
|
.map(|kp| {
|
||||||
|
let target = format!("{host}:{}", kp.port);
|
||||||
|
let (title, severity, cwe, description) = match kp.kind {
|
||||||
|
portscan::PortKind::Ics => (
|
||||||
|
format!("ICS service exposed: {}", kp.service),
|
||||||
|
Severity::High,
|
||||||
|
"CWE-306",
|
||||||
|
format!(
|
||||||
|
"{target} exposes {} ({}). Industrial protocols are typically \
|
||||||
|
unauthenticated, so network reach implies control access.",
|
||||||
|
kp.service, kp.note
|
||||||
|
),
|
||||||
|
),
|
||||||
|
portscan::PortKind::InsecureMgmt => (
|
||||||
|
format!("Cleartext service exposed: {}", kp.service),
|
||||||
|
Severity::Medium,
|
||||||
|
"CWE-319",
|
||||||
|
format!(
|
||||||
|
"{target} exposes {} ({}), which transmits credentials and data in \
|
||||||
|
cleartext.",
|
||||||
|
kp.service, kp.note
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let fp = dedup::compute_fingerprint(&[repo_id, "ics-service-exposed", &target]);
|
||||||
|
let mut f = Finding::new(
|
||||||
|
repo_id.to_string(),
|
||||||
|
fp,
|
||||||
|
"ics-probe".to_string(),
|
||||||
|
ScanType::IcsProbe,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
severity,
|
||||||
|
);
|
||||||
|
f.rule_id = Some("ics-service-exposed".to_string());
|
||||||
|
f.cwe = Some(cwe.to_string());
|
||||||
|
f.remediation = Some(
|
||||||
|
"Restrict the service to a trusted network segment; disable it if unused; \
|
||||||
|
replace cleartext protocols (Telnet/FTP) with SSH/SFTP."
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
f
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Extract `(host, port)` from a target reference. Modbus lives on its own port
|
/// Extract `(host, port)` from a target reference. Modbus lives on its own port
|
||||||
/// (502 by default), independent of any HTTP/WebVisu URL, so unless the reference
|
/// (502 by default), independent of any HTTP/WebVisu URL, so unless the reference
|
||||||
/// explicitly carries `modbus://host:port` or a bare `host:port`, we probe 502.
|
/// explicitly carries `modbus://host:port` or a bare `host:port`, we probe 502.
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! TCP service discovery for a device.
|
||||||
|
//!
|
||||||
|
//! Connect-scans a curated set of OT/ICS and insecure-management ports and reports
|
||||||
|
//! the ones that are open. The deep protocol probes own Modbus (502), OPC UA
|
||||||
|
//! (4840) and EtherNet/IP (44818); this surfaces the *rest* of the industrial and
|
||||||
|
//! cleartext-management surface (Siemens S7, DNP3, CODESYS programming, Telnet, …).
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use futures_util::future::join_all;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
/// Whether an open port is an industrial protocol or an insecure management service.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PortKind {
|
||||||
|
/// An industrial control protocol (typically unauthenticated).
|
||||||
|
Ics,
|
||||||
|
/// A cleartext management service (credentials/data in the clear).
|
||||||
|
InsecureMgmt,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A well-known port worth flagging when open.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct KnownPort {
|
||||||
|
pub port: u16,
|
||||||
|
pub service: &'static str,
|
||||||
|
pub kind: PortKind,
|
||||||
|
pub note: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curated scan list. Excludes 502 / 4840 / 44818 — those have dedicated deep
|
||||||
|
/// probes (Modbus, OPC UA, EtherNet/IP) that report richer findings.
|
||||||
|
pub const KNOWN_PORTS: &[KnownPort] = &[
|
||||||
|
KnownPort {
|
||||||
|
port: 102,
|
||||||
|
service: "S7comm / ISO-TSAP",
|
||||||
|
kind: PortKind::Ics,
|
||||||
|
note: "Siemens S7 PLC communication",
|
||||||
|
},
|
||||||
|
KnownPort {
|
||||||
|
port: 20000,
|
||||||
|
service: "DNP3",
|
||||||
|
kind: PortKind::Ics,
|
||||||
|
note: "SCADA / DNP3",
|
||||||
|
},
|
||||||
|
KnownPort {
|
||||||
|
port: 1911,
|
||||||
|
service: "Niagara Fox",
|
||||||
|
kind: PortKind::Ics,
|
||||||
|
note: "Tridium Niagara building automation",
|
||||||
|
},
|
||||||
|
KnownPort {
|
||||||
|
port: 11740,
|
||||||
|
service: "CODESYS",
|
||||||
|
kind: PortKind::Ics,
|
||||||
|
note: "CODESYS programming protocol",
|
||||||
|
},
|
||||||
|
KnownPort {
|
||||||
|
port: 1962,
|
||||||
|
service: "PCWorx",
|
||||||
|
kind: PortKind::Ics,
|
||||||
|
note: "Phoenix Contact PCWorx",
|
||||||
|
},
|
||||||
|
KnownPort {
|
||||||
|
port: 9600,
|
||||||
|
service: "OMRON FINS",
|
||||||
|
kind: PortKind::Ics,
|
||||||
|
note: "Omron FINS",
|
||||||
|
},
|
||||||
|
KnownPort {
|
||||||
|
port: 789,
|
||||||
|
service: "Red Lion Crimson",
|
||||||
|
kind: PortKind::Ics,
|
||||||
|
note: "Red Lion controllers",
|
||||||
|
},
|
||||||
|
KnownPort {
|
||||||
|
port: 23,
|
||||||
|
service: "Telnet",
|
||||||
|
kind: PortKind::InsecureMgmt,
|
||||||
|
note: "cleartext remote shell",
|
||||||
|
},
|
||||||
|
KnownPort {
|
||||||
|
port: 21,
|
||||||
|
service: "FTP",
|
||||||
|
kind: PortKind::InsecureMgmt,
|
||||||
|
note: "cleartext file transfer",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Connect-scan `ports` on `host` (concurrently) and return those that accept a
|
||||||
|
/// TCP connection.
|
||||||
|
pub async fn scan<'a>(host: &str, ports: &'a [KnownPort], budget: Duration) -> Vec<&'a KnownPort> {
|
||||||
|
let checks = ports.iter().map(|kp| async move {
|
||||||
|
let open = timeout(budget, TcpStream::connect((host, kp.port)))
|
||||||
|
.await
|
||||||
|
.map(|r| r.is_ok())
|
||||||
|
.unwrap_or(false);
|
||||||
|
(kp, open)
|
||||||
|
});
|
||||||
|
join_all(checks)
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(kp, open)| open.then_some(kp))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scan_reports_only_open_ports() {
|
||||||
|
// Bind one port (open) and pick another that is closed.
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||||
|
let open_port = listener.local_addr().expect("addr").port();
|
||||||
|
|
||||||
|
let ports = [
|
||||||
|
KnownPort {
|
||||||
|
port: open_port,
|
||||||
|
service: "test-open",
|
||||||
|
kind: PortKind::Ics,
|
||||||
|
note: "",
|
||||||
|
},
|
||||||
|
KnownPort {
|
||||||
|
port: 1,
|
||||||
|
service: "test-closed",
|
||||||
|
kind: PortKind::InsecureMgmt,
|
||||||
|
note: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
let found = scan("127.0.0.1", &ports, Duration::from_millis(500)).await;
|
||||||
|
let services: Vec<&str> = found.iter().map(|p| p.service).collect();
|
||||||
|
assert_eq!(services, vec!["test-open"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user