mirror of
https://github.com/casterbyte/Sara.git
synced 2025-12-16 03:19:30 +00:00
886 lines
No EOL
30 KiB
Python
886 lines
No EOL
30 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# Sara: MikroTik RouterOS Security Inspector
|
|
# Copyright (c) 2026 Mahama Bazarov
|
|
# Licensed under the Apache 2.0 License
|
|
# 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
|
|
from colorama import Fore, Style
|
|
from packaging.version import Version
|
|
from cve_analyzer import run_cve_audit, run_cve_audit_for_version
|
|
|
|
# init colors
|
|
colorama.init(autoreset=True)
|
|
|
|
INDENT = " "
|
|
|
|
|
|
# print banner
|
|
def banner():
|
|
banner_art = r"""
|
|
_____
|
|
/ ___/____ __________ _
|
|
\__ \/ __ `/ ___/ __ `/
|
|
___/ / /_/ / / / /_/ /
|
|
/____/\__,_/_/ \__,_/
|
|
"""
|
|
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()
|
|
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}")
|
|
|
|
|
|
# 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,
|
|
"username": user,
|
|
"port": port,
|
|
}
|
|
|
|
# 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:
|
|
conn = ConnectHandler(**device)
|
|
ok(f"SSH connection established: {user}@{ip}")
|
|
return conn
|
|
except Exception as e:
|
|
error(f"SSH connection failed: {e}")
|
|
sys.exit(1)
|
|
|
|
|
|
# resolve auth method and prompt
|
|
def normalize_auth_and_prompt(args):
|
|
key_file = args.key
|
|
key_passphrase = None
|
|
|
|
# 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)
|
|
|
|
|
|
# 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))
|
|
info(f"Detected RouterOS: {Fore.MAGENTA}{routeros_version}{Style.RESET_ALL}")
|
|
else:
|
|
error("Could not determine RouterOS version")
|
|
|
|
|
|
# SMB service check
|
|
def check_smb(connection):
|
|
section("SMB Service")
|
|
output = connection.send_command("/ip smb print")
|
|
if "enabled: yes" in output:
|
|
alert("SMB service is enabled! Do you need SMB? Also avoid CVE-2018-7445")
|
|
else:
|
|
ok("SMB is disabled")
|
|
ok("No issues detected")
|
|
|
|
|
|
# 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
|
|
|
|
# scan line by line
|
|
for line in output.splitlines():
|
|
line = line.strip()
|
|
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")
|
|
|
|
if not risks_found:
|
|
ok("No high-risk RMI services detected")
|
|
|
|
|
|
# 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
|
|
|
|
# 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:
|
|
ok("No default usernames found")
|
|
|
|
|
|
# check service address-list on /ip service
|
|
def checking_access_to_RMI(connection):
|
|
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
|
|
|
|
# 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
|
|
|
|
flags_field = line[:idx_name]
|
|
if "X" in flags_field or "D" in flags_field:
|
|
continue
|
|
|
|
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:
|
|
ok("All RMI services have proper IP restrictions")
|
|
|
|
|
|
# WiFi / PMKID / WPS check (/interface/wifi/print detail, RouterOS v7+)
|
|
def check_wifi_security(connection):
|
|
section("WiFi Security")
|
|
try:
|
|
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:
|
|
iface_name = "Unknown"
|
|
|
|
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:
|
|
ok("No risky WiFi security settings detected")
|
|
|
|
|
|
# UPnP check
|
|
def check_upnp_status(connection):
|
|
section("UPnP Status")
|
|
output = connection.send_command("/ip upnp print")
|
|
if "enabled: yes" in output:
|
|
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:
|
|
ok("UPnP is disabled")
|
|
|
|
|
|
# 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:
|
|
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:
|
|
ok("Remote DNS requests are disabled")
|
|
|
|
|
|
# DDNS check
|
|
def check_ddns_status(connection):
|
|
section("DDNS Settings")
|
|
output = connection.send_command("/ip cloud print")
|
|
if "ddns-enabled: yes" in output:
|
|
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:
|
|
ok("DDNS is disabled")
|
|
|
|
|
|
# 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
|
|
|
|
# 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"
|
|
poe_mode = poe_match.group(1) if poe_match else "none"
|
|
|
|
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:
|
|
ok("No PoE-enabled interfaces detected")
|
|
|
|
|
|
# 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:
|
|
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:
|
|
ok("RouterBOOT protection is enabled")
|
|
|
|
|
|
# SOCKS proxy check
|
|
def check_socks_status(connection):
|
|
section("SOCKS Proxy Status")
|
|
output = connection.send_command("/ip socks print")
|
|
if "enabled: yes" in output:
|
|
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:
|
|
ok("SOCKS proxy is disabled")
|
|
|
|
|
|
# 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:
|
|
warn("Bandwidth server is enabled")
|
|
detail("May generate unwanted test traffic")
|
|
detail("Can increase CPU load under active use")
|
|
else:
|
|
ok("Bandwidth server is disabled")
|
|
|
|
|
|
# neighbor discovery config check
|
|
def check_neighbor_discovery(connection):
|
|
section("Neighbor Discovery Protocols")
|
|
output = connection.send_command("/ip neighbor discovery-settings print")
|
|
risks_found = False
|
|
|
|
if "discover-interface-list: all" in output:
|
|
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)
|
|
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")
|
|
|
|
|
|
# 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:
|
|
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")
|
|
|
|
|
|
# SSH security check
|
|
def check_ssh_security(connection):
|
|
section("SSH Security")
|
|
output = connection.send_command("/ip ssh print")
|
|
risks_found = False
|
|
|
|
if "forwarding-enabled: both" in output:
|
|
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:
|
|
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):
|
|
section("Connection Tracking")
|
|
output = connection.send_command("/ip firewall connection tracking print")
|
|
if "enabled: auto" in output or "enabled: on" in output:
|
|
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")
|
|
|
|
|
|
# RoMON check
|
|
def check_romon_status(connection):
|
|
section("RoMON Status")
|
|
output = connection.send_command("/tool romon print")
|
|
if "enabled: yes" in output:
|
|
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")
|
|
|
|
|
|
# MAC Winbox / MAC Telnet / MAC ping checks
|
|
def check_mac_winbox_security(connection):
|
|
section("Winbox MAC Server Settings")
|
|
|
|
# MAC Winbox
|
|
try:
|
|
output = connection.send_command("/tool mac-server mac-winbox print")
|
|
if "allowed-interface-list" in output:
|
|
if "allowed-interface-list: all" in output:
|
|
warn("MAC Winbox access is allowed on all interfaces")
|
|
detail("Limit MAC Winbox to management or trusted segments only")
|
|
else:
|
|
ok("MAC Winbox is restricted to specific interfaces")
|
|
else:
|
|
# legacy layout
|
|
if re.search(r"\bINTERFACE\s*\n.*\ball\b", output, re.DOTALL | re.IGNORECASE):
|
|
warn("MAC Winbox access is allowed on all interfaces (legacy format)")
|
|
detail("Limit MAC Winbox to management or trusted segments only")
|
|
else:
|
|
ok("MAC Winbox is properly restricted (legacy format)")
|
|
except Exception as e:
|
|
error(f"Error while checking MAC Winbox: {e}")
|
|
|
|
# MAC Telnet
|
|
try:
|
|
output = connection.send_command("/tool mac-server print")
|
|
if "allowed-interface-list" in output:
|
|
if "allowed-interface-list: all" in output:
|
|
warn("MAC Telnet access is allowed on all interfaces")
|
|
detail("Limit MAC Telnet to management or trusted segments only")
|
|
else:
|
|
ok("MAC Telnet is restricted to specific interfaces")
|
|
else:
|
|
if re.search(r"\bINTERFACE\s*\n.*\ball\b", output, re.DOTALL | re.IGNORECASE):
|
|
warn("MAC Telnet access is allowed on all interfaces (legacy format)")
|
|
detail("Limit MAC Telnet to management or trusted segments only")
|
|
else:
|
|
ok("MAC Telnet is properly restricted (legacy format)")
|
|
except Exception as e:
|
|
error(f"Error while checking MAC Telnet: {e}")
|
|
|
|
# MAC ping
|
|
try:
|
|
output = connection.send_command("/tool mac-server ping print")
|
|
if "enabled: yes" in output:
|
|
warn("MAC Ping is enabled")
|
|
detail("May generate unnecessary Layer 2 broadcast traffic")
|
|
else:
|
|
ok("MAC Ping is restricted or disabled")
|
|
except Exception as e:
|
|
error(f"Error while checking MAC Ping: {e}")
|
|
|
|
|
|
# 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
|
|
|
|
# scan table
|
|
for line in output.splitlines():
|
|
match = re.search(r'^\s*\d+\s+[*X]?\s*([\w-]+)', line)
|
|
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:
|
|
ok("SNMP community strings checked - no weak values detected")
|
|
|
|
|
|
# dst-nat / netmap rules check
|
|
def check_dst_nat_rules(connection):
|
|
section("Firewall NAT Rules")
|
|
output = connection.send_command("/ip firewall nat print")
|
|
dst_nat_rules = []
|
|
|
|
for line in output.splitlines():
|
|
if "action=dst-nat" in line or "action=netmap" in line:
|
|
dst_nat_rules.append(line.strip())
|
|
|
|
if dst_nat_rules:
|
|
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:
|
|
detail(rule)
|
|
else:
|
|
ok("No Destination NAT (dst-nat/netmap) rules detected")
|
|
|
|
|
|
# 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()
|
|
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 ""
|
|
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)
|
|
|
|
issues = []
|
|
|
|
# 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:
|
|
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")
|
|
|
|
# dangerous policies
|
|
dangerous_policies = {"password", "sensitive", "sniff", "ftp"}
|
|
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)}")
|
|
|
|
# reboots
|
|
if "reboot" in event:
|
|
if interval_value and interval_unit in ["s", "m", "h"] and interval_value < 12:
|
|
issues.append(f"Frequently reboots router ({interval_value}{interval_unit}) - possible anti-forensics")
|
|
else:
|
|
issues.append("Schedules router reboot - verify it is intentional")
|
|
|
|
# tight intervals
|
|
if interval_value and interval_unit in ["s", "m", "h"] and interval_value < 25:
|
|
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:
|
|
ok("No suspicious schedulers detected")
|
|
|
|
|
|
# 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")
|
|
|
|
# parse entries
|
|
for entry in entry_blocks:
|
|
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:
|
|
warn("Static DNS entries are configured")
|
|
detail("Verify that each record is legitimate and expected")
|
|
for name, address in dns_entries:
|
|
detail(f"{name} → {address}")
|
|
detail("Attackers often modify DNS for phishing or traffic redirection during post-exploitation")
|
|
else:
|
|
ok("No static DNS entries found")
|
|
|
|
|
|
# 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"}
|
|
|
|
if not selected:
|
|
error("No profiles specified")
|
|
info("Use at least one profile: system, protocols, wifi")
|
|
sys.exit(1)
|
|
|
|
invalid = selected - valid
|
|
if invalid:
|
|
error("Unknown profiles: " + ", ".join(sorted(invalid)))
|
|
info("Valid profiles: system, protocols, wifi")
|
|
sys.exit(1)
|
|
|
|
return selected
|
|
|
|
|
|
# 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})")
|
|
|
|
profiles = parse_profiles(args.profiles)
|
|
password, key_file, key_passphrase = normalize_auth_and_prompt(args)
|
|
connection = connect_to_router(
|
|
args.ip,
|
|
args.username,
|
|
password=password,
|
|
port=args.port,
|
|
key_file=key_file,
|
|
key_passphrase=key_passphrase,
|
|
)
|
|
|
|
# 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)
|
|
return
|
|
|
|
# mode 2: live device
|
|
ip = args.mode_or_ip
|
|
username = args.username_or_version
|
|
|
|
# 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()
|
|
print(f"[*] Disconnected from RouterOS ({ip})")
|
|
|
|
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)
|
|
|
|
if __name__ == "__main__":
|
|
main() |