ot ics security

Detecting Attacks on Historian Servers

Detect cyber attacks targeting OT historian servers (OSIsoft PI, Ignition, Wonderware) that sit at the IT/OT boundary and serve as pivot points for lateral movement between enterprise and control networks, including data manipulation, unauthorized queries, and exploitation of historian-specific vulnerabilities.

data-integrityhistorianicsignitionlateral-movementosisoft-piot-securitypivot-point
Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-Skills
Framework mappings

When to Use

  • When monitoring historian servers that bridge IT and OT networks for compromise indicators
  • When detecting unauthorized queries or data manipulation in process historian databases
  • When investigating lateral movement through historian servers between IT and OT zones
  • When responding to alerts about exploitation of historian-specific vulnerabilities (CVE-2025-0921)
  • When validating historian data integrity after a suspected OT security incident

Do not use for general database security monitoring (see database security skills), for historian deployment and configuration, or for IT-only data warehouse security.

Prerequisites

  • Historian server inventory (OSIsoft PI, Ignition, GE Proficy, Wonderware InSQL)
  • Network monitoring on historian network segments (both IT-facing and OT-facing interfaces)
  • Historian API access for data integrity validation
  • Baseline of normal historian query patterns (which applications query which tags)
  • Understanding of historian architecture (data sources, interfaces, client connections)

Workflow

Step 1: Monitor Historian for Attack Indicators

#!/usr/bin/env python3
"""OT Historian Attack Detector.
 
Monitors historian servers for unauthorized access, data manipulation,
lateral movement indicators, and exploitation of historian-specific
vulnerabilities. Supports OSIsoft PI and Ignition platforms.
"""
 
import json
import sys
from collections import defaultdict
from datetime import datetime, timedelta
from typing import Dict, List, Optional
 
try:
    import requests
except ImportError:
    print("Install requests: pip install requests")
    sys.exit(1)
 
 
class HistorianAttackDetector:
    """Detects attacks targeting OT historian servers."""
 
    def __init__(self, historian_type: str, historian_url: str,
                 api_credentials: dict, verify_ssl: bool = False):
        self.historian_type = historian_type
        self.historian_url = historian_url.rstrip("/")
        self.credentials = api_credentials
        self.verify_ssl = verify_ssl
        self.alerts = []
        self.authorized_clients = set()
        self.authorized_queries = {}
 
    def set_baseline(self, authorized_clients: List[str],
                     authorized_query_patterns: Dict[str, List[str]]):
        """Set baseline of authorized historian clients and query patterns."""
        self.authorized_clients = set(authorized_clients)
        self.authorized_queries = authorized_query_patterns
 
    def check_active_connections(self) -> List[dict]:
        """Check for unauthorized connections to historian."""
        connections = []
 
        if self.historian_type == "osisoft_pi":
            try:
                resp = requests.get(
                    f"{self.historian_url}/piwebapi/system/status",
                    auth=(self.credentials.get("username"), self.credentials.get("password")),
                    verify=self.verify_ssl,
                    timeout=10,
                )
                if resp.status_code == 200:
                    data = resp.json()
                    connections = data.get("ConnectedClients", [])
            except requests.RequestException as e:
                print(f"[!] PI Web API error: {e}")
 
        elif self.historian_type == "ignition":
            try:
                resp = requests.get(
                    f"{self.historian_url}/data/status/connections",
                    headers={"Authorization": f"Bearer {self.credentials.get('token')}"},
                    verify=self.verify_ssl,
                    timeout=10,
                )
                if resp.status_code == 200:
                    connections = resp.json().get("connections", [])
            except requests.RequestException as e:
                print(f"[!] Ignition API error: {e}")
 
        # Check for unauthorized clients
        for conn in connections:
            client_ip = conn.get("client_ip", conn.get("address", ""))
            if self.authorized_clients and client_ip not in self.authorized_clients:
                self.alerts.append({
                    "severity": "HIGH",
                    "type": "UNAUTHORIZED_HISTORIAN_CLIENT",
                    "timestamp": datetime.now().isoformat(),
                    "source_ip": client_ip,
                    "details": f"Unauthorized client {client_ip} connected to {self.historian_type} historian",
                    "mitre": "T0802 - Automated Collection",
                })
 
        return connections
 
    def check_data_integrity(self, tags: List[str], hours_back: int = 24):
        """Check historian data for manipulation indicators."""
        print(f"[*] Checking data integrity for {len(tags)} tags over last {hours_back}h")
 
        integrity_issues = []
        for tag in tags:
            try:
                if self.historian_type == "osisoft_pi":
                    resp = requests.get(
                        f"{self.historian_url}/piwebapi/streams/{tag}/recorded",
                        params={"startTime": f"*-{hours_back}h", "endTime": "*"},
                        auth=(self.credentials.get("username"), self.credentials.get("password")),
                        verify=self.verify_ssl,
                        timeout=15,
                    )
                    if resp.status_code == 200:
                        items = resp.json().get("Items", [])
                        # Check for suspicious patterns
                        if len(items) == 0:
                            integrity_issues.append({
                                "tag": tag, "issue": "NO_DATA",
                                "detail": "No data points in expected timeframe - possible deletion",
                            })
                        else:
                            values = [i.get("Value", 0) for i in items if isinstance(i.get("Value"), (int, float))]
                            if values and len(set(values)) == 1 and len(values) > 100:
                                integrity_issues.append({
                                    "tag": tag, "issue": "FLATLINE",
                                    "detail": f"Constant value {values[0]} for {len(values)} points - possible replay/spoofing",
                                })
            except requests.RequestException:
                pass
 
        for issue in integrity_issues:
            self.alerts.append({
                "severity": "HIGH",
                "type": f"DATA_INTEGRITY_{issue['issue']}",
                "timestamp": datetime.now().isoformat(),
                "tag": issue["tag"],
                "details": issue["detail"],
                "mitre": "T0809 - Data Destruction" if issue["issue"] == "NO_DATA" else "T0832 - Manipulation of View",
            })
 
        return integrity_issues
 
    def check_lateral_movement_indicators(self):
        """Check for indicators of historian being used as pivot point."""
        indicators = []
 
        # Check 1: Historian making outbound connections to Level 1 devices
        # (Historian should receive data, not initiate connections to PLCs)
        indicators.append({
            "check": "Outbound connections to PLC subnets",
            "description": "Historian initiating connections to Level 1 devices may indicate compromise",
            "detection": "Monitor firewall logs for historian IP connecting to PLC ports (502, 102, 44818)",
        })
 
        # Check 2: New processes or services on historian
        indicators.append({
            "check": "Unauthorized processes on historian server",
            "description": "Attackers may install tools on historian for lateral movement",
            "detection": "Monitor process creation events (Sysmon EventID 1) on historian",
        })
 
        # Check 3: Unusual authentication to historian
        indicators.append({
            "check": "Authentication from unexpected sources",
            "description": "Compromised IT systems authenticating to historian for pivoting",
            "detection": "Monitor Windows Security Event 4624 for logons from non-baseline sources",
        })
 
        return indicators
 
    def generate_report(self):
        """Generate historian attack detection report."""
        print(f"\n{'='*70}")
        print("HISTORIAN ATTACK DETECTION REPORT")
        print(f"{'='*70}")
        print(f"Historian Type: {self.historian_type}")
        print(f"Historian URL: {self.historian_url}")
        print(f"Report Time: {datetime.now().isoformat()}")
        print(f"Total Alerts: {len(self.alerts)}")
 
        if self.alerts:
            print(f"\n--- ALERTS ---")
            for alert in self.alerts:
                print(f"\n  [{alert['severity']}] {alert['type']}")
                print(f"    Time: {alert['timestamp']}")
                print(f"    Detail: {alert['details']}")
                print(f"    MITRE ICS: {alert.get('mitre', 'N/A')}")
 
        print(f"\n--- LATERAL MOVEMENT CHECKS ---")
        for indicator in self.check_lateral_movement_indicators():
            print(f"\n  Check: {indicator['check']}")
            print(f"    Risk: {indicator['description']}")
            print(f"    Detection: {indicator['detection']}")
 
 
if __name__ == "__main__":
    detector = HistorianAttackDetector(
        historian_type="osisoft_pi",
        historian_url="https://pi-server.plant.local",
        api_credentials={"username": "pi_reader", "password": "api_key_here"},
    )
 
    detector.set_baseline(
        authorized_clients=["10.10.2.10", "10.10.2.20", "10.10.3.50", "10.10.150.10"],
        authorized_query_patterns={},
    )
 
    detector.check_active_connections()
    detector.check_data_integrity(tags=["REACTOR_01.TEMP", "PUMP_03.FLOW"], hours_back=24)
    detector.generate_report()

Key Concepts

Term Definition
OT Historian Database server (OSIsoft PI, Ignition, Wonderware) storing time-series process data from SCADA/DCS systems
Pivot Point Historian's position between IT and OT networks makes it a prime target for attackers to move between zones
Data Replay Attack Feeding historical data to an HMI to mask real-time process manipulation (Stuxnet technique)
OSIsoft PI Most widely deployed OT historian, used by 65% of Global 500 process companies
Ignition Inductive Automation SCADA platform with historian module, increasingly targeted due to Python scripting capabilities
CVE-2025-0921 Ignition SCADA privileged file system vulnerability allowing escalation through malicious project files

Output Format

HISTORIAN ATTACK DETECTION REPORT
====================================
Historian: [type and hostname]
Date: YYYY-MM-DD
 
CONNECTION ANALYSIS:
  Authorized Clients: [count]
  Unauthorized Clients Detected: [count with IPs]
 
DATA INTEGRITY:
  Tags Checked: [count]
  Integrity Issues: [count]
  Flatline Detections: [count]
  Data Gaps: [count]
 
LATERAL MOVEMENT INDICATORS:
  Outbound PLC Connections: [found/not found]
  Unauthorized Processes: [found/not found]
  Anomalous Authentication: [found/not found]
Source materials

References and resources

Everything below is rendered for inspection. Script files are read-only and never run.

References 1

api-reference.md1.9 KB

Historian Server Attack Detection — API Reference

Common Historian Platforms

Platform Vendor Default Port
PI Data Archive OSIsoft/AVEVA 5457
PI Web API OSIsoft/AVEVA 443/5459
Wonderware Historian AVEVA 1433 (SQL)
FactoryTalk Historian Rockwell 1433 (SQL)
Ignition Gateway Inductive Automation 8088
iFIX Historian GE Digital 5051

OSIsoft PI Web API Endpoints

Method Endpoint Description
GET /piwebapi/system System information and version
GET /piwebapi/points List PI data points
GET /piwebapi/streams/{webId}/value Get current point value
GET /piwebapi/streams/{webId}/recorded Get historical recorded values
GET /piwebapi/dataservers List configured data servers

Ignition Gateway Endpoints

Endpoint Description
/StatusPing Gateway health check
/system/gwinfo Gateway system information
/system/webdev Web development module
/main/web/status Gateway status page

Attack Indicators

Indicator Description Severity
Anonymous API access PI Web API accessible without auth CRITICAL
Bulk data read >10,000 points read in single session CRITICAL
Brute force login >5 failed logins from same IP HIGH
Exposed gateway info Ignition/PI info pages publicly accessible HIGH
SQL injection on historian DB Direct SQL queries to historian backend CRITICAL

External References

Scripts 1

agent.py7.0 KB
Display-only source. This catalog never executes bundled scripts.
#!/usr/bin/env python3
"""Historian server attack detection agent for ICS/SCADA environments."""

import json
import os
import sys
import argparse
import socket
from datetime import datetime

try:
    import requests
except ImportError:
    print("Install: pip install requests")
    sys.exit(1)


HISTORIAN_PORTS = {
    5450: "OSIsoft PI AF",
    5457: "OSIsoft PI Data Archive",
    5459: "OSIsoft PI Web API",
    1433: "SQL Server (Wonderware/FactoryTalk)",
    3306: "MySQL (Ignition)",
    8088: "Ignition Gateway",
    443: "HTTPS (PI Web API / Ignition)",
}


def scan_historian_ports(host):
    """Scan for exposed historian service ports."""
    results = []
    for port, service in HISTORIAN_PORTS.items():
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.settimeout(3)
            status = sock.connect_ex((host, port)) == 0
            sock.close()
            result = {"host": host, "port": port, "service": service, "open": status}
            if status:
                result["finding"] = f"Historian port {port} ({service}) accessible"
                result["severity"] = "HIGH"
            results.append(result)
        except socket.error:
            pass
    return results


def check_pi_web_api(host, username=None, password=None):
    """Check OSIsoft PI Web API for authentication and configuration issues."""
    base = f"https://{host}/piwebapi"
    auth = (username, password) if username else None
    results = {"host": host, "checks": []}

    try:
        resp = requests.get(f"{base}/system", auth=auth,
                            verify=not os.environ.get("SKIP_TLS_VERIFY", "").lower() == "true", timeout=10)  # Set SKIP_TLS_VERIFY=true for self-signed certs in lab environments
        if resp.status_code == 200:
            data = resp.json()
            results["product_version"] = data.get("ProductTitle", "")
            results["checks"].append({
                "check": "PI Web API accessible",
                "status": "PASS" if auth else "FAIL",
                "detail": "Anonymous access enabled" if not auth and resp.status_code == 200 else "",
                "severity": "CRITICAL" if not auth else "INFO",
            })
    except requests.exceptions.ConnectionError:
        results["checks"].append({"check": "PI Web API", "status": "UNREACHABLE"})
    except Exception as e:
        results["error"] = str(e)

    try:
        resp = requests.get(f"{base}/points", auth=auth,
                            verify=not os.environ.get("SKIP_TLS_VERIFY", "").lower() == "true",
                            params={"maxCount": 10}, timeout=10)
        if resp.status_code == 200:
            points = resp.json().get("Items", [])
            results["exposed_points"] = len(points)
            results["sample_points"] = [p.get("Name", "") for p in points[:5]]
            if not auth:
                results["checks"].append({
                    "check": "Point data accessible without auth",
                    "status": "FAIL",
                    "severity": "CRITICAL",
                })
    except Exception:
        pass

    return results


def check_ignition_gateway(host, port=8088):
    """Check Inductive Automation Ignition gateway status."""
    results = {"host": host, "port": port}
    try:
        resp = requests.get(f"http://{host}:{port}/StatusPing", timeout=10)
        if resp.status_code == 200:
            results["gateway_accessible"] = True
            results["response"] = resp.text[:200]

        resp2 = requests.get(f"http://{host}:{port}/system/gwinfo", timeout=10)
        if resp2.status_code == 200:
            results["gateway_info_exposed"] = True
            results["finding"] = "Ignition gateway info page accessible"
            results["severity"] = "HIGH"
    except Exception as e:
        results["error"] = str(e)
    return results


def analyze_historian_logs(log_entries):
    """Analyze historian access logs for attack indicators."""
    findings = []
    failed_logins = {}
    bulk_reads = {}

    for entry in log_entries:
        if entry.get("event_type") == "login_failed":
            src = entry.get("src_ip", "")
            failed_logins[src] = failed_logins.get(src, 0) + 1
        if entry.get("event_type") == "data_read" and entry.get("point_count", 0) > 1000:
            src = entry.get("src_ip", "")
            bulk_reads[src] = bulk_reads.get(src, 0) + entry["point_count"]

    for ip, count in failed_logins.items():
        if count > 5:
            findings.append({
                "ip": ip,
                "issue": f"Brute force attempt: {count} failed logins",
                "severity": "HIGH",
            })

    for ip, points in bulk_reads.items():
        if points > 10000:
            findings.append({
                "ip": ip,
                "issue": f"Bulk data exfiltration: {points} points read",
                "severity": "CRITICAL",
            })

    return findings


def run_audit(args):
    """Execute historian server attack detection audit."""
    print(f"\n{'='*60}")
    print(f"  HISTORIAN SERVER ATTACK DETECTION")
    print(f"  Generated: {datetime.utcnow().isoformat()} UTC")
    print(f"{'='*60}\n")

    report = {}

    if args.host:
        port_scan = scan_historian_ports(args.host)
        open_ports = [p for p in port_scan if p.get("open")]
        report["port_scan"] = port_scan
        print(f"--- HISTORIAN PORT SCAN ({args.host}) ---")
        for p in open_ports:
            print(f"  [{p.get('severity','INFO')}] Port {p['port']}: {p['service']}")
        if not open_ports:
            print("  No historian ports detected")

    if args.pi_host:
        pi = check_pi_web_api(args.pi_host, args.pi_user, args.pi_pass)
        report["pi_web_api"] = pi
        print(f"\n--- PI WEB API CHECK ---")
        for c in pi.get("checks", []):
            print(f"  [{c.get('severity','INFO')}] {c['check']}: {c['status']}")

    if args.ignition_host:
        ign = check_ignition_gateway(args.ignition_host, args.ignition_port or 8088)
        report["ignition_gateway"] = ign
        print(f"\n--- IGNITION GATEWAY CHECK ---")
        print(f"  Accessible: {ign.get('gateway_accessible', False)}")
        if ign.get("finding"):
            print(f"  [{ign['severity']}] {ign['finding']}")

    return report


def main():
    parser = argparse.ArgumentParser(description="Historian Attack Detection Agent")
    parser.add_argument("--host", help="Historian server to scan")
    parser.add_argument("--pi-host", help="OSIsoft PI Web API host")
    parser.add_argument("--pi-user", help="PI username")
    parser.add_argument("--pi-pass", help="PI password")
    parser.add_argument("--ignition-host", help="Ignition gateway host")
    parser.add_argument("--ignition-port", type=int, default=8088)
    parser.add_argument("--output", help="Save report to JSON file")
    args = parser.parse_args()

    report = run_audit(args)
    if args.output:
        with open(args.output, "w") as f:
            json.dump(report, f, indent=2, default=str)
        print(f"\n[+] Report saved to {args.output}")


if __name__ == "__main__":
    main()
Keep exploring