//! Minimal Modbus/TCP client for dynamic ICS probing. //! //! Modbus/TCP (port 502) has no authentication or encryption in the protocol, so //! an endpoint that answers requests is, by design, open to any host that can //! reach it. The probe only *reads* — a Read Holding Registers request and a Read //! Device Identification request — and never writes to the live process. use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::timeout; /// Outcome of probing a Modbus/TCP endpoint. #[derive(Debug, Default, PartialEq, Eq)] pub struct ModbusProbe { /// A TCP connection to the port was established. pub reachable: bool, /// The endpoint answered a Modbus request (a normal reply or a Modbus /// exception) — i.e. it speaks Modbus, unauthenticated. pub speaks_modbus: bool, /// Device identity, if disclosed via Read Device Identification (FC 43 / 14). pub device: Option, /// Coils returned by a Read Coils of the first block, if that address range /// exists. Coils are read/write process bits, so an exposed block is an /// unauthenticated write surface on the live process. pub coils_readable: Option, /// Holding registers returned by a Read Holding Registers of the first block, /// if that range exists. Holding registers are read/write process words. pub holding_registers_readable: Option, } /// Vendor / product / revision from Read Device Identification. #[derive(Debug, Default, PartialEq, Eq)] pub struct DeviceId { pub vendor: Option, pub product: Option, pub revision: Option, } /// How many coils / holding registers to request when enumerating the exposed /// process surface. Read-only: a normal reply means the block exists and is, /// over unauthenticated Modbus/TCP, also writable. const ENUM_QTY: u16 = 16; /// Probe a Modbus/TCP endpoint. Read-only: issues Read Holding Registers, Read /// Coils, and Read Device Identification requests; never writes to the device. pub async fn probe(host: &str, port: u16, budget: Duration) -> ModbusProbe { let mut out = ModbusProbe::default(); let Ok(Ok(mut stream)) = timeout(budget, TcpStream::connect((host, port))).await else { return out; // unreachable }; out.reachable = true; // Read Holding Registers (FC 0x03), unit 1, addr 0 — a benign read that also // enumerates the exposed register block. let rhr = [0x03u8, 0x00, 0x00, (ENUM_QTY >> 8) as u8, ENUM_QTY as u8]; if let Some(resp) = txn(&mut stream, 1, &rhr, budget).await { // A normal reply (0x03) or an exception (0x83) both prove it speaks Modbus. if matches!(resp.first(), Some(0x03) | Some(0x83)) { out.speaks_modbus = true; } if resp.first() == Some(&0x03) { out.holding_registers_readable = Some(register_count_from_reply(&resp)); } } // Read Coils (FC 0x01), addr 0 — enumerates the exposed coil (bit) block. let rc = [0x01u8, 0x00, 0x00, (ENUM_QTY >> 8) as u8, ENUM_QTY as u8]; if let Some(resp) = txn(&mut stream, 1, &rc, budget).await { if matches!(resp.first(), Some(0x01) | Some(0x81)) { out.speaks_modbus = true; } if resp.first() == Some(&0x01) { out.coils_readable = Some(coil_count_from_reply(&resp)); } } // Read Device Identification (FC 0x2B / MEI 0x0E), basic (0x01), object 0. let rdi = [0x2Bu8, 0x0E, 0x01, 0x00]; if let Some(resp) = txn(&mut stream, 1, &rdi, budget).await { if resp.first() == Some(&0x2B) { out.speaks_modbus = true; out.device = parse_device_id(&resp); } } out } /// Coils reported by a Read Coils reply `[0x01, byte_count, data…]` (8 per byte). fn coil_count_from_reply(pdu: &[u8]) -> u16 { pdu.get(1).map(|&b| u16::from(b) * 8).unwrap_or(0) } /// Registers reported by a Read Holding Registers reply `[0x03, byte_count, /// data…]` (2 bytes per register). fn register_count_from_reply(pdu: &[u8]) -> u16 { pdu.get(1).map(|&b| u16::from(b) / 2).unwrap_or(0) } /// Send one Modbus PDU and return the response PDU (function code + data), or /// `None` on timeout / malformed reply. async fn txn(stream: &mut TcpStream, unit: u8, pdu: &[u8], budget: Duration) -> Option> { // MBAP header: transaction id (2) + protocol id (2) = 0 + length (2) + unit (1), // then the PDU. `length` counts the unit byte plus the PDU. let len = (pdu.len() + 1) as u16; let mut frame = Vec::with_capacity(7 + pdu.len()); frame.extend_from_slice(&[0x00, 0x01]); // transaction id frame.extend_from_slice(&[0x00, 0x00]); // protocol id frame.extend_from_slice(&len.to_be_bytes()); frame.push(unit); frame.extend_from_slice(pdu); timeout(budget, stream.write_all(&frame)).await.ok()?.ok()?; let mut hdr = [0u8; 7]; timeout(budget, stream.read_exact(&mut hdr)) .await .ok()? .ok()?; // Reject non-Modbus replies (protocol id must be 0). if hdr[2] != 0 || hdr[3] != 0 { return None; } let plen = u16::from_be_bytes([hdr[4], hdr[5]]) as usize; if !(2..=260).contains(&plen) { return None; } let mut body = vec![0u8; plen - 1]; // minus the unit id already in hdr[6] timeout(budget, stream.read_exact(&mut body)) .await .ok()? .ok()?; Some(body) } /// Parse vendor / product / revision from a Read Device Identification PDU: /// `[0x2B, 0x0E, readDevIdCode, conformity, moreFollows, nextObjId, numObjects, /// (objId, len, bytes…)…]`. fn parse_device_id(pdu: &[u8]) -> Option { if pdu.len() < 7 { return None; } let num = pdu[6] as usize; let mut i = 7; let mut dev = DeviceId::default(); for _ in 0..num { if i + 2 > pdu.len() { break; } let id = pdu[i]; let l = pdu[i + 1] as usize; i += 2; if i + l > pdu.len() { break; } let val = String::from_utf8_lossy(&pdu[i..i + l]).trim().to_string(); i += l; match id { 0x00 => dev.vendor = Some(val), 0x01 => dev.product = Some(val), 0x02 => dev.revision = Some(val), _ => {} } } if dev == DeviceId::default() { None } else { Some(dev) } } #[cfg(test)] mod tests { use super::*; use tokio::net::TcpListener; /// A one-shot mock Modbus/TCP server that answers a Read Holding Registers /// request and a Read Device Identification request on one connection. async fn mock_server(with_device: bool) -> 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"); loop { let mut hdr = [0u8; 7]; if sock.read_exact(&mut hdr).await.is_err() { break; } let plen = u16::from_be_bytes([hdr[4], hdr[5]]) as usize; let mut pdu = vec![0u8; plen - 1]; if sock.read_exact(&mut pdu).await.is_err() { break; } let reply_pdu: Vec = match pdu.first() { Some(0x03) => vec![0x03, 0x02, 0x00, 0x00], // 1 register (byte_count 2) Some(0x01) => vec![0x01, 0x02, 0xFF, 0xFF], // 16 coils (byte_count 2) Some(0x2B) if with_device => vec![ 0x2B, 0x0E, 0x01, 0x81, 0x00, 0x00, 0x02, // 2 objects 0x00, 0x04, b'A', b'C', b'M', b'E', // vendor 0x01, 0x03, b'P', b'L', b'C', // product ], _ => vec![pdu[0] | 0x80, 0x01], // exception }; let len = (reply_pdu.len() + 1) as u16; let mut frame = vec![hdr[0], hdr[1], 0x00, 0x00]; frame.extend_from_slice(&len.to_be_bytes()); frame.push(hdr[6]); frame.extend_from_slice(&reply_pdu); if sock.write_all(&frame).await.is_err() { break; } } }); addr } #[tokio::test] async fn probe_detects_a_modbus_endpoint_and_reads_device_id() { let addr = mock_server(true).await; let p = probe(&addr.ip().to_string(), addr.port(), Duration::from_secs(2)).await; assert!(p.reachable && p.speaks_modbus); let dev = p.device.expect("device id"); assert_eq!(dev.vendor.as_deref(), Some("ACME")); assert_eq!(dev.product.as_deref(), Some("PLC")); } #[tokio::test] async fn probe_enumerates_exposed_process_points() { let addr = mock_server(false).await; let p = probe(&addr.ip().to_string(), addr.port(), Duration::from_secs(2)).await; assert!(p.speaks_modbus); // The mock returns a 2-byte holding-register block (1 register) and a // 2-byte coil block (16 coils). assert_eq!(p.holding_registers_readable, Some(1)); assert_eq!(p.coils_readable, Some(16)); } #[test] fn reply_counts_decode_byte_counts() { assert_eq!(register_count_from_reply(&[0x03, 0x08]), 4); // 8 bytes → 4 regs assert_eq!(coil_count_from_reply(&[0x01, 0x03]), 24); // 3 bytes → 24 coils assert_eq!(register_count_from_reply(&[0x03]), 0); // malformed → 0 } #[tokio::test] async fn probe_reports_unreachable_for_a_closed_port() { // 127.0.0.1:1 is (almost certainly) closed. let p = probe("127.0.0.1", 1, Duration::from_millis(500)).await; assert!(!p.reachable && !p.speaks_modbus); } #[test] fn parses_device_identification_objects() { let pdu = [ 0x2B, 0x0E, 0x01, 0x81, 0x00, 0x00, 0x01, // 1 object 0x02, 0x05, b'v', b'1', b'.', b'2', b'3', // revision ]; let dev = parse_device_id(&pdu).expect("device"); assert_eq!(dev.revision.as_deref(), Some("v1.23")); assert!(dev.vendor.is_none()); } }