Sara/sara.py

886 lines
30 KiB
Python
Raw Normal View History

2024-08-17 20:00:26 +05:00
#!/usr/bin/env python3
2025-12-09 16:30:21 +04:00
# Sara: MikroTik RouterOS Security Inspector
# Copyright (c) 2026 Mahama Bazarov
# Licensed under the Apache 2.0 License
2025-12-09 16:30:21 +04:00
# This project is not affiliated with or endorsed by SIA Mikrotīkls
import argparse
import colorama
import re
import sys
import os
from getpass import getpass
from netmiko import ConnectHandler
2024-08-17 20:00:26 +05:00
from colorama import Fore, Style
from packaging.version import Version
2025-12-09 16:30:21 +04:00
from cve_analyzer import run_cve_audit, run_cve_audit_for_version
2024-08-17 20:00:26 +05:00
2025-12-09 16:30:21 +04:00
# init colors
2024-08-17 20:00:26 +05:00
colorama.init(autoreset=True)
2025-12-09 16:30:21 +04:00
INDENT = " "
# print banner
2024-08-17 20:00:26 +05:00
def banner():
2025-12-09 16:30:21 +04:00
banner_art = r"""
_____
/ ___/____ __________ _
\__ \/ __ `/ ___/ __ `/
___/ / /_/ / / / /_/ /
/____/\__,_/_/ \__,_/
2024-08-17 20:00:26 +05:00
"""
2025-12-09 16:30:21 +04:00
print(INDENT + banner_art)
print(INDENT + "Sara: " + Style.RESET_ALL + "MikroTik RouterOS Security Inspector")
print(INDENT + "Developer: " + Style.RESET_ALL + "Mahama Bazarov (Caster)")
print(INDENT + "Contact: " + Style.RESET_ALL + "mahamabazarov@mailbox.org")
print(INDENT + "Version: " + Style.RESET_ALL + "1.3.0")
print(INDENT + "Documentation & Usage: " + Style.RESET_ALL + "https://github.com/caster0x00/Sara")
# section header
def section(title: str):
print()
2025-12-09 16:30:21 +04:00
print(Fore.WHITE + f"[+] {title}" + Style.RESET_ALL)
# info line
def info(msg: str):
print(Fore.WHITE + INDENT + f"[*] {msg}")
# ok line
def ok(msg: str):
print(Fore.GREEN + INDENT + f"[✓] {msg}")
# warning line
def warn(msg: str):
print(Fore.YELLOW + INDENT + f"[!] {msg}")
2025-12-09 16:30:21 +04:00
# high severity line
def alert(msg: str):
print(Fore.RED + INDENT + f"[!] {msg}")
# error line
def error(msg: str):
print(Fore.RED + INDENT + f"[-] {msg}")
# detailed line
def detail(msg: str):
print(Fore.LIGHTWHITE_EX + INDENT * 2 + f"[*] {msg}" + Style.RESET_ALL)
# ssh connection helper
def connect_to_router(ip, user, password=None, port=22, key_file=None, key_passphrase=None):
device = {
"device_type": "mikrotik_routeros",
"host": ip,
2025-12-09 16:30:21 +04:00
"username": user,
"port": port,
2024-08-17 20:00:26 +05:00
}
2025-12-09 16:30:21 +04:00
# key-based auth
if key_file:
key_path = os.path.expanduser(key_file)
if not os.path.exists(key_path):
error(f"SSH key not found: {key_path}")
info("Provide path to the private key (not .pub)")
sys.exit(1)
device["use_keys"] = True
device["key_file"] = key_path
if key_passphrase:
device["passphrase"] = key_passphrase
else:
# password auth
if not password:
error("No authentication method provided")
sys.exit(1)
device["password"] = password
device["use_keys"] = False
# connect
try:
2025-12-09 16:30:21 +04:00
conn = ConnectHandler(**device)
ok(f"SSH connection established: {user}@{ip}")
return conn
except Exception as e:
2025-12-09 16:30:21 +04:00
error(f"SSH connection failed: {e}")
sys.exit(1)
2025-12-09 16:30:21 +04:00
# resolve auth method and prompt
def normalize_auth_and_prompt(args):
key_file = args.key
key_passphrase = None
2025-12-09 16:30:21 +04:00
# key flow
if key_file:
key_file = os.path.expanduser(key_file)
if not os.path.exists(key_file):
error(f"SSH key file not found: {key_file}")
info("Provide path to the private key (not .pub)")
sys.exit(1)
prompt = f"[?] Passphrase for key {key_file} (leave empty if none): "
entered = getpass(prompt)
if entered:
key_passphrase = entered
return None, key_file, key_passphrase
# password flow
password = getpass(f"[?] SSH password for {args.username}@{args.ip}: ")
return password, None, None
# simple version wrapper
def parse_version(version_str):
return Version(version_str)
2025-12-09 16:30:21 +04:00
# detect and print RouterOS version
def check_routeros_version(connection):
# run resource print
output = connection.send_command("/system resource print")
match = re.search(r"version:\s*([\d.]+)", output)
if match:
routeros_version = parse_version(match.group(1))
2025-12-09 16:30:21 +04:00
info(f"Detected RouterOS: {Fore.MAGENTA}{routeros_version}{Style.RESET_ALL}")
else:
2025-12-09 16:30:21 +04:00
error("Could not determine RouterOS version")
2025-12-09 16:30:21 +04:00
# SMB service check
def check_smb(connection):
2025-12-09 16:30:21 +04:00
section("SMB Service")
output = connection.send_command("/ip smb print")
if "enabled: yes" in output:
2025-12-09 16:30:21 +04:00
alert("SMB service is enabled! Do you need SMB? Also avoid CVE-2018-7445")
else:
2025-12-09 16:30:21 +04:00
ok("SMB is disabled")
ok("No issues detected")
2025-12-09 16:30:21 +04:00
# RMI services exposure check
def check_rmi_services(connection):
section("Remote Management (RMI/MGMT)")
output = connection.send_command("/ip service print")
high_risk = ["telnet", "ftp", "www"]
moderate_risk = ["api", "api-ssl", "winbox", "www-ssl"]
ssh = ["ssh"]
risks_found = False
2025-12-09 16:30:21 +04:00
# scan line by line
for line in output.splitlines():
line = line.strip()
2025-12-09 16:30:21 +04:00
if not line:
continue
# skip disabled/default
if re.search(r"^\d+\s+X\b", line):
continue
if re.search(r"^\d+\s+D\b", line):
continue
match = re.search(r"(\S+)\s+\d+", line)
if not match:
continue
service_name = match.group(1).lower()
display_name = service_name.upper().replace("WWW", "HTTP").replace("WWW-SSL", "HTTPS")
# high risk
if service_name in high_risk:
alert(f"{display_name} is enabled (high risk)")
if service_name == "ftp":
warn("FTP transmits credentials in cleartext")
if service_name == "telnet":
warn("Telnet allows credential interception")
if service_name == "www":
warn("HTTP credentials can be sniffed over the network")
risks_found = True
continue
# medium risk
if service_name in moderate_risk:
warn(f"{display_name} is enabled")
if service_name in ["api", "api-ssl"]:
info("RouterOS API is a brute-force target; restrict access")
elif service_name == "www-ssl":
info("Ensure HTTPS uses strong ciphers and valid certificates")
elif service_name == "winbox":
warn("Winbox enabled. Winbox 'Keep Password' may store credentials in plaintext. If the PC is compromised, saved passwords may be extracted!")
continue
# ssh
if service_name in ssh:
ok(f"{display_name} enabled. Use strong passwords or SSH keys for authentication")
2024-08-17 20:00:26 +05:00
if not risks_found:
2025-12-09 16:30:21 +04:00
ok("No high-risk RMI services detected")
2024-08-17 20:00:26 +05:00
2025-12-09 16:30:21 +04:00
# default usernames check
def check_default_users(connection):
section("Default Usernames")
output = connection.send_command("/user print detail")
default_users = {"admin", "engineer", "user", "test", "root", "mikrotik", "routeros"}
risks_found = False
2025-12-09 16:30:21 +04:00
# split user blocks
for block in output.split("\n\n"):
match = re.search(r'name="([^"]+)"', block)
if not match:
continue
username = match.group(1).lower()
if username in default_users:
warn(f"Default username detected: '{username}'")
info("Change it to a unique value to reduce attack surface")
risks_found = True
if not risks_found:
2025-12-09 16:30:21 +04:00
ok("No default usernames found")
2025-12-09 16:30:21 +04:00
# check service address-list on /ip service
def checking_access_to_RMI(connection):
2025-12-09 16:30:21 +04:00
section("RMI/MGMT Access Restrictions")
output = connection.send_command("/ip service print")
lines = output.splitlines()
header_line = None
# find header
for line in lines:
if line.strip().startswith("#"):
header_line = line
break
if not header_line:
error("Unable to parse /ip service print header")
return
try:
idx_name = header_line.index("NAME")
idx_addr = header_line.index("ADDRESS")
idx_cert = header_line.index("CERTIFICATE") if "CERTIFICATE" in header_line else None
except ValueError:
error("Expected columns NAME/ADDRESS not found in /ip service print output")
return
risks_found = False
2025-12-09 16:30:21 +04:00
# parse body
for line in lines:
if not line.strip():
continue
if line == header_line or line.strip().startswith("Flags:") or line.strip().startswith("Columns:"):
continue
stripped = line.lstrip()
if not stripped or not stripped[0].isdigit():
continue
2025-12-09 16:30:21 +04:00
flags_field = line[:idx_name]
if "X" in flags_field or "D" in flags_field:
continue
2025-12-09 16:30:21 +04:00
service_name_raw = line[idx_name:idx_addr].strip()
if not service_name_raw:
continue
service_name = service_name_raw.upper()
if idx_cert is not None and len(line) > idx_addr:
addr_raw = line[idx_addr:idx_cert]
else:
addr_raw = line[idx_addr:]
address = addr_raw.strip()
# empty address -> no restrictions
if not address:
alert(f"{service_name} has no IP restriction")
risks_found = True
else:
ok(f"{service_name} restricted to: {address}")
if not risks_found:
2025-12-09 16:30:21 +04:00
ok("All RMI services have proper IP restrictions")
2025-12-09 16:30:21 +04:00
# WiFi / PMKID / WPS check (/interface/wifi/print detail, RouterOS v7+)
def check_wifi_security(connection):
2025-12-09 16:30:21 +04:00
section("WiFi Security")
try:
2025-12-09 16:30:21 +04:00
output = connection.send_command("/interface/wifi/print detail")
except Exception as e:
error(f"Error while checking WiFi: {e}")
return
if not output.strip():
ok("No WiFi interfaces found")
return
interfaces = output.split("\n\n")
risks_found = False
# scan interfaces
for iface in interfaces:
if not iface.strip():
continue
name_match = re.search(r'\bname="([^"]+)"', iface)
if not name_match:
name_match = re.search(r'\bdefault-name="([^"]+)"', iface)
if name_match:
iface_name = name_match.group(1)
else:
2025-12-09 16:30:21 +04:00
iface_name = "Unknown"
2025-12-09 16:30:21 +04:00
pmkid_enabled = re.search(r'\.disable-pmkid=no\b', iface)
wps_push = re.search(r'\.wps=push-button\b', iface)
if pmkid_enabled or wps_push:
warn(f"WiFi interface '{iface_name}' has potentially weak security settings")
if pmkid_enabled:
detail("PMKID is enabled (.disable-pmkid=no) - allows offline PMKID-based attacks on WPA/WPA2-PSK")
if wps_push:
detail("WPS push-button is enabled (.wps=push-button) - WPS is a known attack surface; disable it in production")
risks_found = True
if not risks_found:
2025-12-09 16:30:21 +04:00
ok("No risky WiFi security settings detected")
2025-12-09 16:30:21 +04:00
# UPnP check
def check_upnp_status(connection):
section("UPnP Status")
output = connection.send_command("/ip upnp print")
if "enabled: yes" in output:
2025-12-09 16:30:21 +04:00
alert("UPnP is enabled")
detail("UPnP allows automatic port forwarding to internal hosts")
detail("Can expose devices to the Internet without your awareness")
detail("Ensure this was intentionally enabled")
else:
2025-12-09 16:30:21 +04:00
ok("UPnP is disabled")
2025-12-09 16:30:21 +04:00
# DNS behavior check
def check_dns_status(connection):
section("DNS Settings")
output = connection.send_command("/ip dns print")
if "allow-remote-requests: yes" in output:
2025-12-09 16:30:21 +04:00
warn("Router is acting as a DNS server")
detail("DNS queries from the network are accepted")
detail("Ensure DNS is not exposed on external interfaces")
else:
2025-12-09 16:30:21 +04:00
ok("Remote DNS requests are disabled")
2025-12-09 16:30:21 +04:00
# DDNS check
def check_ddns_status(connection):
section("DDNS Settings")
output = connection.send_command("/ip cloud print")
if "ddns-enabled: yes" in output:
2025-12-09 16:30:21 +04:00
warn("Dynamic DNS is enabled")
detail("Your router may become reachable via a public hostname")
detail("Ensure this is needed for remote access or VPN setups")
else:
2025-12-09 16:30:21 +04:00
ok("DDNS is disabled")
2025-12-09 16:30:21 +04:00
# PoE check
def check_poe_status(connection):
section("PoE Status")
output = connection.send_command("/interface ethernet print detail")
interfaces = output.split("\n\n")
risks_found = False
2025-12-09 16:30:21 +04:00
# inspect each port
for iface in interfaces:
if not iface.strip():
continue
name_match = re.search(r'name="([^"]+)"', iface)
poe_match = re.search(r'poe-out=(\S+)', iface)
name = name_match.group(1) if name_match else "Unknown"
2025-12-09 16:30:21 +04:00
poe_mode = poe_match.group(1) if poe_match else "none"
2025-12-09 16:30:21 +04:00
if poe_mode in ("auto-on", "forced-on"):
warn(f"PoE is enabled on interface '{name}'")
detail("Ensure connected devices support PoE to avoid hardware damage")
risks_found = True
if not risks_found:
2025-12-09 16:30:21 +04:00
ok("No PoE-enabled interfaces detected")
2025-12-09 16:30:21 +04:00
# RouterBOOT protection check
def check_routerboot_protection(connection):
section("RouterBOOT Protection")
output = connection.send_command("/system routerboard settings print")
if "protected-routerboot: disabled" in output:
2025-12-09 16:30:21 +04:00
alert("RouterBOOT protection is disabled")
detail("Device can be reset or reflashed via Netinstall without authentication")
detail("Enable 'protected-routerboot' to prevent unauthorized boot changes")
else:
2025-12-09 16:30:21 +04:00
ok("RouterBOOT protection is enabled")
2025-12-09 16:30:21 +04:00
# SOCKS proxy check
def check_socks_status(connection):
section("SOCKS Proxy Status")
output = connection.send_command("/ip socks print")
if "enabled: yes" in output:
2025-12-09 16:30:21 +04:00
alert("SOCKS proxy is enabled")
detail("SOCKS may indicate unauthorized tunneling or compromise")
detail("Attackers often use SOCKS as a pivot into internal networks")
detail("Disable unless explicitly required for your environment")
else:
2025-12-09 16:30:21 +04:00
ok("SOCKS proxy is disabled")
2025-12-09 16:30:21 +04:00
# bandwidth-server check
def check_bandwidth_server_status(connection):
section("Bandwidth Server Status")
output = connection.send_command("/tool bandwidth-server print")
if "enabled: yes" in output:
2025-12-09 16:30:21 +04:00
warn("Bandwidth server is enabled")
detail("May generate unwanted test traffic")
detail("Can increase CPU load under active use")
else:
2025-12-09 16:30:21 +04:00
ok("Bandwidth server is disabled")
2025-12-09 16:30:21 +04:00
# neighbor discovery config check
def check_neighbor_discovery(connection):
2025-12-09 16:30:21 +04:00
section("Neighbor Discovery Protocols")
output = connection.send_command("/ip neighbor discovery-settings print")
risks_found = False
if "discover-interface-list: all" in output:
2025-12-09 16:30:21 +04:00
warn("Discovery packets are sent on all interfaces")
detail("This allows attackers to map RouterOS presence on multiple segments")
risks_found = True
protocol_match = re.search(r'protocol: ([\w,]+)', output)
if protocol_match:
protocols = protocol_match.group(1)
2025-12-09 16:30:21 +04:00
warn(f"Neighbor discovery protocols enabled: {protocols}")
detail("Limit discovery to management or trusted interfaces only")
risks_found = True
if not risks_found:
ok("No security risks found in Neighbor Discovery configuration")
2025-12-09 16:30:21 +04:00
# password policy check
def check_password_length_policy(connection):
section("Password Policy")
output = connection.send_command("/user settings print")
if "minimum-password-length: 0" in output:
2025-12-09 16:30:21 +04:00
warn("No minimum password length is enforced")
detail("Short passwords significantly reduce brute-force resistance")
detail("Set a minimum length (e.g. 10-12 characters or more)")
else:
ok("Password length policy is enforced")
2025-12-09 16:30:21 +04:00
# SSH security check
def check_ssh_security(connection):
2025-12-09 16:30:21 +04:00
section("SSH Security")
output = connection.send_command("/ip ssh print")
risks_found = False
if "forwarding-enabled: both" in output:
2025-12-09 16:30:21 +04:00
warn("SSH dynamic port forwarding is enabled")
detail("May be used as a tunneling/pivoting channel")
detail("Verify this is required and properly restricted")
risks_found = True
if "strong-crypto: no" in output:
2025-12-09 16:30:21 +04:00
warn("Strong SSH crypto is disabled")
detail("Enable 'strong-crypto' to enforce stronger ciphers and MACs")
detail("Disables weak algorithms (MD5, null encryption, small DH groups)")
risks_found = True
if not risks_found:
ok("SSH security settings are properly configured")
# Connection tracking check
def check_connection_tracking(connection):
2025-12-09 16:30:21 +04:00
section("Connection Tracking")
output = connection.send_command("/ip firewall connection tracking print")
if "enabled: auto" in output or "enabled: on" in output:
2025-12-09 16:30:21 +04:00
warn("Connection tracking is enabled")
detail("RouterOS tracks connection states for firewall/NAT")
detail("On pure transit routers without NAT, disabling may reduce CPU load")
else:
ok("Connection tracking is configured appropriately")
2025-12-09 16:30:21 +04:00
# RoMON check
def check_romon_status(connection):
section("RoMON Status")
output = connection.send_command("/tool romon print")
if "enabled: yes" in output:
2025-12-09 16:30:21 +04:00
warn("RoMON is enabled")
detail("Provides Layer 2 management access to RouterOS devices")
detail("Disable RoMON if not explicitly required to reduce attack surface")
else:
ok("RoMON is disabled")
2025-12-09 16:30:21 +04:00
# MAC Winbox / MAC Telnet / MAC ping checks
def check_mac_winbox_security(connection):
2025-12-09 16:30:21 +04:00
section("Winbox MAC Server Settings")
2025-12-09 16:30:21 +04:00
# MAC Winbox
2025-05-29 22:01:57 +05:00
try:
2025-12-09 16:30:21 +04:00
output = connection.send_command("/tool mac-server mac-winbox print")
2025-05-29 22:01:57 +05:00
if "allowed-interface-list" in output:
if "allowed-interface-list: all" in output:
2025-12-09 16:30:21 +04:00
warn("MAC Winbox access is allowed on all interfaces")
detail("Limit MAC Winbox to management or trusted segments only")
2025-05-29 22:01:57 +05:00
else:
2025-12-09 16:30:21 +04:00
ok("MAC Winbox is restricted to specific interfaces")
2025-05-29 22:01:57 +05:00
else:
2025-12-09 16:30:21 +04:00
# legacy layout
2025-05-29 22:01:57 +05:00
if re.search(r"\bINTERFACE\s*\n.*\ball\b", output, re.DOTALL | re.IGNORECASE):
2025-12-09 16:30:21 +04:00
warn("MAC Winbox access is allowed on all interfaces (legacy format)")
detail("Limit MAC Winbox to management or trusted segments only")
2025-05-29 22:01:57 +05:00
else:
2025-12-09 16:30:21 +04:00
ok("MAC Winbox is properly restricted (legacy format)")
2025-05-29 22:01:57 +05:00
except Exception as e:
2025-12-09 16:30:21 +04:00
error(f"Error while checking MAC Winbox: {e}")
2025-12-09 16:30:21 +04:00
# MAC Telnet
2025-05-29 22:01:57 +05:00
try:
2025-12-09 16:30:21 +04:00
output = connection.send_command("/tool mac-server print")
2025-05-29 22:01:57 +05:00
if "allowed-interface-list" in output:
if "allowed-interface-list: all" in output:
2025-12-09 16:30:21 +04:00
warn("MAC Telnet access is allowed on all interfaces")
detail("Limit MAC Telnet to management or trusted segments only")
2025-05-29 22:01:57 +05:00
else:
2025-12-09 16:30:21 +04:00
ok("MAC Telnet is restricted to specific interfaces")
2025-05-29 22:01:57 +05:00
else:
if re.search(r"\bINTERFACE\s*\n.*\ball\b", output, re.DOTALL | re.IGNORECASE):
2025-12-09 16:30:21 +04:00
warn("MAC Telnet access is allowed on all interfaces (legacy format)")
detail("Limit MAC Telnet to management or trusted segments only")
2025-05-29 22:01:57 +05:00
else:
2025-12-09 16:30:21 +04:00
ok("MAC Telnet is properly restricted (legacy format)")
2025-05-29 22:01:57 +05:00
except Exception as e:
2025-12-09 16:30:21 +04:00
error(f"Error while checking MAC Telnet: {e}")
2025-12-09 16:30:21 +04:00
# MAC ping
2025-05-29 22:01:57 +05:00
try:
2025-12-09 16:30:21 +04:00
output = connection.send_command("/tool mac-server ping print")
2025-05-29 22:01:57 +05:00
if "enabled: yes" in output:
2025-12-09 16:30:21 +04:00
warn("MAC Ping is enabled")
detail("May generate unnecessary Layer 2 broadcast traffic")
2025-05-29 22:01:57 +05:00
else:
2025-12-09 16:30:21 +04:00
ok("MAC Ping is restricted or disabled")
2025-05-29 22:01:57 +05:00
except Exception as e:
2025-12-09 16:30:21 +04:00
error(f"Error while checking MAC Ping: {e}")
2025-12-09 16:30:21 +04:00
# SNMP communities check
def check_snmp(connection):
section("SNMP Community Strings")
output = connection.send_command("/snmp community print")
bad_names = {"public", "private", "admin", "mikrotik", "mikrotik_admin", "root", "routeros", "zabbix"}
risks_found = False
2025-12-09 16:30:21 +04:00
# scan table
for line in output.splitlines():
match = re.search(r'^\s*\d+\s+[*X]?\s*([\w-]+)', line)
2025-12-09 16:30:21 +04:00
if not match:
continue
community_name = match.group(1).lower()
if community_name in bad_names:
warn(f"Weak SNMP community string detected: '{community_name}'")
detail("Change it to a long, random value and restrict source IPs")
risks_found = True
if not risks_found:
2025-12-09 16:30:21 +04:00
ok("SNMP community strings checked - no weak values detected")
2025-12-09 16:30:21 +04:00
# dst-nat / netmap rules check
def check_dst_nat_rules(connection):
2025-12-09 16:30:21 +04:00
section("Firewall NAT Rules")
output = connection.send_command("/ip firewall nat print")
dst_nat_rules = []
2025-12-09 16:30:21 +04:00
for line in output.splitlines():
if "action=dst-nat" in line or "action=netmap" in line:
dst_nat_rules.append(line.strip())
2025-12-09 16:30:21 +04:00
if dst_nat_rules:
2025-12-09 16:30:21 +04:00
warn("Destination NAT (dst-nat/netmap) rules detected")
detail("Exposing internal services to the Internet can be dangerous")
detail("Verify that each rule is intentional and properly restricted")
for rule in dst_nat_rules:
2025-12-09 16:30:21 +04:00
detail(rule)
else:
ok("No Destination NAT (dst-nat/netmap) rules detected")
2025-12-09 16:30:21 +04:00
# scheduler / persistence check
def detect_malicious_schedulers(connection):
section("Schedulers & Persistence")
output = connection.send_command("/system scheduler print detail")
risks_found = False
fetch_files = set()
2025-12-09 16:30:21 +04:00
tasks = output.split("\n\n")
# first pass: track fetch targets
for task in tasks:
if not task.strip():
continue
event_match = re.search(r'on-event="?([^"\n]+)"?', task)
event = event_match.group(1).strip() if event_match else ""
fetch_match = re.search(r'dst-path=([\S]+)', event)
if "fetch" in event and fetch_match:
fetched_file = fetch_match.group(1).strip(";")
fetch_files.add(fetched_file)
# second pass: analyze schedulers
for task in tasks:
if not task.strip():
continue
name_match = re.search(r'name="?([^"]+)"?', task)
event_match = re.search(r'on-event="?([^"\n]+)"?', task)
policy_match = re.search(r'policy=([\w,]+)', task)
interval_match = re.search(r'interval=(\d+)([smhd])', task)
name = name_match.group(1) if name_match else "Unknown"
event = event_match.group(1).strip() if event_match else ""
2025-12-09 16:30:21 +04:00
policies = policy_match.group(1).split(",") if policy_match else []
interval_value, interval_unit = (int(interval_match.group(1)), interval_match.group(2)) if interval_match else (None, None)
2025-12-09 16:30:21 +04:00
issues = []
2025-12-09 16:30:21 +04:00
# fetch + import chain
import_match = re.search(r'import\s+([\S]+)', event)
if "import" in event and import_match:
imported_file = import_match.group(1).strip(";")
if imported_file in fetch_files:
2025-12-09 16:30:21 +04:00
issues.append("Imports a previously fetched script - potential backdoor")
if interval_value and interval_unit:
issues.append(f"Runs every {interval_value}{interval_unit}, ensures persistence")
2025-12-09 16:30:21 +04:00
# dangerous policies
dangerous_policies = {"password", "sensitive", "sniff", "ftp"}
2025-12-09 16:30:21 +04:00
used_dangerous = [p for p in policies if p in dangerous_policies]
if used_dangerous:
issues.append(f"Uses high-privilege policies: {', '.join(used_dangerous)}")
2025-12-09 16:30:21 +04:00
# reboots
if "reboot" in event:
if interval_value and interval_unit in ["s", "m", "h"] and interval_value < 12:
2025-12-09 16:30:21 +04:00
issues.append(f"Frequently reboots router ({interval_value}{interval_unit}) - possible anti-forensics")
else:
2025-12-09 16:30:21 +04:00
issues.append("Schedules router reboot - verify it is intentional")
2025-12-09 16:30:21 +04:00
# tight intervals
if interval_value and interval_unit in ["s", "m", "h"] and interval_value < 25:
2025-12-09 16:30:21 +04:00
issues.append(f"Executes too frequently ({interval_value}{interval_unit}) - may indicate persistence or botnet-like activity")
if issues:
alert(f"Scheduler '{name}' looks suspicious")
for msg in issues:
detail(msg)
risks_found = True
if not risks_found:
2025-12-09 16:30:21 +04:00
ok("No suspicious schedulers detected")
2025-12-09 16:30:21 +04:00
# static DNS entries check
def check_static_dns_entries(connection):
section("Static DNS Entries")
output = connection.send_command("/ip dns static print detail")
dns_entries = []
entry_blocks = output.split("\n\n")
2025-12-09 16:30:21 +04:00
# parse entries
for entry in entry_blocks:
2025-12-09 16:30:21 +04:00
if not entry.strip():
continue
name_match = re.search(r'name="([^"]+)"', entry)
address_match = re.search(r'address=([\d.]+)', entry)
if name_match and address_match:
name = name_match.group(1)
address = address_match.group(1)
dns_entries.append((name, address))
if dns_entries:
2025-12-09 16:30:21 +04:00
warn("Static DNS entries are configured")
detail("Verify that each record is legitimate and expected")
for name, address in dns_entries:
2025-12-09 16:30:21 +04:00
detail(f"{name}{address}")
detail("Attackers often modify DNS for phishing or traffic redirection during post-exploitation")
else:
2025-12-09 16:30:21 +04:00
ok("No static DNS entries found")
2025-12-09 16:30:21 +04:00
# parse audit profiles
def parse_profiles(profiles_str):
raw = [p.strip().lower() for p in profiles_str.split(",")]
selected = {p for p in raw if p}
valid = {"system", "protocols", "wifi"}
2025-05-29 22:01:57 +05:00
2025-12-09 16:30:21 +04:00
if not selected:
error("No profiles specified")
info("Use at least one profile: system, protocols, wifi")
sys.exit(1)
2025-12-09 16:30:21 +04:00
invalid = selected - valid
if invalid:
error("Unknown profiles: " + ", ".join(sorted(invalid)))
info("Valid profiles: system, protocols, wifi")
sys.exit(1)
2025-05-29 22:01:57 +05:00
2025-12-09 16:30:21 +04:00
return selected
2025-12-09 16:30:21 +04:00
# main audit dispatcher
def run_sara_audit(args):
section("Sara Audit Mode")
info(f"Target Device: {args.ip}")
info(f"Transport: SSH (port {args.port})")
2025-12-09 16:30:21 +04:00
profiles = parse_profiles(args.profiles)
password, key_file, key_passphrase = normalize_auth_and_prompt(args)
2025-05-29 22:01:57 +05:00
connection = connect_to_router(
args.ip,
args.username,
2025-12-09 16:30:21 +04:00
password=password,
port=args.port,
key_file=key_file,
key_passphrase=key_passphrase,
)
2025-12-09 16:30:21 +04:00
# system profile
if "system" in profiles:
check_routeros_version(connection)
check_default_users(connection)
check_rmi_services(connection)
checking_access_to_RMI(connection)
check_poe_status(connection)
check_routerboot_protection(connection)
check_bandwidth_server_status(connection)
check_password_length_policy(connection)
check_ssh_security(connection)
check_connection_tracking(connection)
check_romon_status(connection)
check_mac_winbox_security(connection)
check_dst_nat_rules(connection)
detect_malicious_schedulers(connection)
# protocols profile
if "protocols" in profiles:
check_smb(connection)
check_upnp_status(connection)
check_socks_status(connection)
check_dns_status(connection)
check_static_dns_entries(connection)
check_ddns_status(connection)
check_neighbor_discovery(connection)
check_snmp(connection)
# wifi profile
if "wifi" in profiles:
check_wifi_security(connection)
connection.disconnect()
print(f"[*] Disconnected from RouterOS ({args.ip})")
# CVE command dispatcher
def run_cve_command(args):
# mode 1: manual version
if args.mode_or_ip.lower() == "version":
version = args.username_or_version.strip()
section("CVE Search (Manual)")
info(f"RouterOS Version: {version}")
# always pass string here
run_cve_audit_for_version(version)
2025-05-29 22:01:57 +05:00
return
2025-12-09 16:30:21 +04:00
# mode 2: live device
ip = args.mode_or_ip
username = args.username_or_version
2025-12-09 16:30:21 +04:00
# reuse auth helper
args.ip = ip
args.username = username
section("CVE Search (Live)")
info(f"Target Device: {ip}")
info(f"Transport: SSH (port {args.port})")
password, key_file, key_passphrase = normalize_auth_and_prompt(args)
connection = connect_to_router(
ip,
username,
password=password,
port=args.port,
key_file=key_file,
key_passphrase=key_passphrase,
)
# here we pass connection, not version string
run_cve_audit(connection)
connection.disconnect()
2025-12-09 16:30:21 +04:00
print(f"[*] Disconnected from RouterOS ({ip})")
2025-12-09 16:30:21 +04:00
def main():
banner()
parser = argparse.ArgumentParser()
sub = parser.add_subparsers(dest="command", required=True)
# Audit mode
audit = sub.add_parser("audit", help="Run RouterOS security configuration audit")
audit.add_argument("ip", help="RouterOS IP address")
audit.add_argument("username", help="SSH username")
audit.add_argument("profiles", help="Profiles: system,protocols,wifi (comma-separated)")
audit.add_argument("key", nargs="?", default=None, help="Path to SSH private key (optional)")
audit.add_argument("port", nargs="?", type=int, default=22, help="SSH port (default: 22)")
audit.set_defaults(func=run_sara_audit)
# CVE mode
cve = sub.add_parser("cve", help="Run RouterOS CVE audit (live or by version)")
cve.add_argument("mode_or_ip", help="'version' or RouterOS IP address")
cve.add_argument("username_or_version", help="SSH username or RouterOS version string")
cve.add_argument("key", nargs="?", default=None, help="Path to SSH private key (optional)")
cve.add_argument("port", nargs="?", type=int, default=22, help="SSH port (default: 22)")
cve.set_defaults(func=run_cve_command)
# no args = help
if len(sys.argv) == 1:
parser.print_help()
sys.exit(0)
try:
# dispatch
args = parser.parse_args()
args.func(args)
except KeyboardInterrupt:
print()
error("Interrupted by user")
sys.exit(1)
2024-08-17 20:00:26 +05:00
if __name__ == "__main__":
2025-05-29 22:01:57 +05:00
main()