Install this skill
npx skills add mukul975/Anthropic-Cybersecurity-SkillsFramework mappings
MITRE ATT&CK
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