diff --git a/README.md b/README.md index 6a84218..bb92833 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,21 @@ -# Sara: RouterOS Security Inspector +# Sara: MikroTik RouterOS Security Inspector -RouterOS configuration analyzer to find security misconfigurations and vulnerabilities. +RouterOS security analyzer for detecting misconfigurations, weak settings, and known vulnerabilities (CVE). -![](/banner/banner.png) +![](cover/saracover.png) -``` -RouterOS Security Inspector. For security engineers -Operates remotely using SSH, designed to evaluate RouterOS security +```bash + _____ + / ___/____ __________ _ + \__ \/ __ `/ ___/ __ `/ + ___/ / /_/ / / / /_/ / + /____/\__,_/_/ \__,_/ -Author: Magama Bazarov, -Alias: Caster -Version: 1.1.1 -Codename: Judge + Sara: MikroTik RouterOS Security Inspector + Developer: Mahama Bazarov (Caster) + Contact: mahamabazarov@mailbox.org + Version: 1.3.0 + Documentation & Usage: https://github.com/caster0x00/Sara ``` # Disclaimer @@ -28,7 +32,7 @@ The author does not take any responsibility for the misuse of this tool, includi # Sara is not an attack tool -**Sara does not bypass authentication, exploit vulnerabilities, or alter RouterOS configurations.** It works in **read-only mode**, requiring no administrative privileges. +**Sara does not bypass authentication, exploit vulnerabilities, or alter RouterOS configurations.** It works in read-only mode and does not modify device configuration. A read-only RouterOS account is sufficient. If you are unsure about the interpretation of the analysis results, consult an experienced network engineer before making any decisions! @@ -40,117 +44,95 @@ Before use, ensure that your device auditing complies with your organization's l - Use it only on your devices or with the owner's permission; - Do not use Sara on other people's networks without the owner's explicit consent - this may violate computer security laws! -# Mechanism +# Features -**Sara** uses [netmiko](https://github.com/ktbyers/netmiko) to remotely connect via SSH to RouterOS devices. It executes RouterOS system commands to extract configuration data and analyze it for potential vulnerabilities and signs of compromise. The user connects to the hardware himself using Sara by entering his username and password. Sara executes exactly `print` based commands, thus not changing the configuration of your hardware in any way. So, by the way, you can even use an RO-only account if you want to. +**Sara** uses [netmiko](https://github.com/ktbyers/netmiko) to remotely connect via SSH to RouterOS devices. It executes RouterOS system commands to extract configuration data and analyze it for potential vulnerabilities and signs of compromise. The user connects to the hardware himself using Sara by entering his username and password. Sara executes only `print` commands and does not change RouterOS configuration in any way. You can even use a read-only (RO) account if you want to. Sara does not use any exploits, payloads or bruteforce attacks. All RouterOS security analysis here is based on pure configuration analysis. -## What exactly is Sara checking for? +## Profiles -1. **SMB protocol activity** – determines whether SMB is enabled, which may be vulnerable to CVE-2018-7445; +Sara uses audit profiles. Each profile covers its own audit scope. -2. **Check the status of RMI interfaces** – identifies active management services (Telnet, FTP, Winbox, API, HTTP/HTTPS); +- `system`: This profile covers RouterOS system checks. It checks the system version, account status, remote management services and their availability, IP restrictions, PoE and RouterBOOT status, SSH settings, and password policies. It also analyzes MAC Winbox/Telnet services, NAT rules, connection tracking mode, RoMON, and the scheduler, including for suspicious automatic tasks and possible persistence mechanisms. +- `protocols`: The profile focuses on network protocols and services that are commonly used as entry points for attacks. It checks SMB, UPnP, SOCKS proxies, DNS settings and static DNS records, DDNS cloud services, Neighbor Discovery settings, and SNMP. The profile's task is to identify enabled or improperly restricted network services that could increase the attack surface. +- `wifi`: This profile evaluates the security of the wireless part of RouterOS. It analyzes Wi-Fi interfaces, PMKID parameters, and WPS modes that could open up opportunities for offline attacks or unauthorized connections. The profile helps you quickly understand whether the current Wi-Fi network security configuration is weak. -3. **Wi-Fi Security Check** – determines whether WPS and PMKID support are enabled, which can be used in WPA2-PSK attacks; - - > At the moment, this check has minor stability issues, as different versions of RouterOS have different variations of Wi-Fi configurations. Keep that in mind, but feel free to make an issue, we'll look into it; - -4. **Check UPnP** – determines whether UPnP is enabled, which can automatically forward ports and threaten network security; - -5. **Check DNS settings** – detects whether `allow-remote-requests`, which makes the router a DNS server, is enabled; - -6. **Check DDNS** – determines whether dynamic DNS is enabled, which can reveal the real IP address of the device; - -7. **PoE Test** – checks if PoE is enabled, which may cause damage to connected devices; - -8. **Check RouterBOOT security** – determines if RouterBOOT bootloader protection is enabled; - -9. **Check SOCKS Proxy** – identifies an active SOCKS Proxy that could be used by an attacker for pivoting, as well as indicating a potential compromise of the device. - -10. **Bandwidth Server Test (BTest)** – determines whether a bandwidth server is enabled that can be used for a Flood attack by the attacker; - -11. **Check discovery protocols** – determines whether CDP, LLDP, MNDP that can disclose network information are active; - -12. **Check minimum password length** – determines whether the `minimum-password-length` parameter is set to prevent the use of weak passwords; - -13. **SSH Check** – analyzes SSH settings, including the use of strong-crypto and Port Forwarding permission; - -14. **Check Connection Tracking** – determines whether Connection Tracking is enabled, which can increase the load and open additional attack vectors; - -15. **RoMON check** – detects RoMON activity, which allows you to manage devices at Layer 2; - -16. **Check Winbox MAC Server** – analyzes access by MAC address via Winbox and Telnet, which can be a vulnerability on a local network; - -17. **Check SNMP** – detects the use of weak SNMP community strings (`public`, `private`); - -18. **Check NAT rules** – analyzes port forwarding (`dst-nat`, `netmap`) that may allow access to internal services from the outside; - -19. **Check network access to RMI** – determines whether access to critical services (API, Winbox, SSH) is restricted to trusted IPs only; - -20. **Check RouterOS version** – analyzes the current version of RouterOS and compares it to known vulnerable versions; - -21. **RouterOS Vulnerability Check** – checks the RouterOS version against the CVE database and displays a list of known vulnerabilities; - -22. **“Keep Password” in Winbox** – warns of potential use of the “Keep Password” feature - -23. **Check default usernames** – defines the use of standard logins (`admin`, `engineer`, `test`, `mikrotik`); - -24. **Checking the schedulers** – detects malicious tasks that can load remote scripts, perform hidden reboots, or run too often; - -25. **Check static DNS records** – Analyzes static DNS records that can be used for phishing and MITM attacks. - -## A breakdown of one technique - -Sara analyzes MikroTik RouterOS configuration by sending commands via SSH and interpreting the results. Let's consider a basic example of checking an SMB service that may be vulnerable to CVE-2018-7445. - -```python -# SMB Check -def check_smb(connection): - separator("Checking SMB Service") - command = "/ip smb print" - output = connection.send_command(command) - - if "enabled: yes" in output: - print(Fore.RED + Style.BRIGHT + "[*] CAUTION: SMB service is enabled! Are you sure you want it? Also, avoid CVE-2018-7445") - else: - print(Fore.GREEN + "[+] SMB is disabled. No risk detected.") - print(Fore.GREEN + "[+] No issues found.") -``` - -1. Sending a command to the router: command = `/ip smb print` - queries the status of the SMB service; -2. `output = connection.send_command(command)` - executes the command via SSH and receives its output, writing it to the variable memory; -3. If the output contains the string `“enabled: yes”`, then SMB is enabled and the script displays a warning. - -The same principle works for the other checks. Only read the configuration and then analyze it in detail. - -# Vulnerability Search (CVE) +# CVE Search Sara performs a security analysis of RouterOS by checking the current firmware version and checking it against a database of known vulnerabilities (CVEs). This process identifies critical vulnerabilities that can be exploited by attackers to compromise the device. -## But how does it work? +## How does it work? -1. Sara extracts the current RouterOS version from the device using the system command (`/system resource print`) +Sara uses a separate module called `cve_analyzer.py`. +It downloads information from the NVD (National Vulnerability Database), filters it by RouterOS, and generates a local file called `routeros_cves.json` +Next, the RouterOS version is compared with the version ranges specified in CVE records, as well as with additional patterns extracted from vulnerability descriptions. -2. The check is performed using the built-in `cve_lookup.py` module, which stores a dictionary of known RouterOS vulnerabilities. This module is based on data obtained [from the MITRE CVE database](https://cve.mitre.org/data/downloads) and contains: +There are two ways to perform the CVE check: - - CVE ID; - - Vulnerability Description; - - Range of vulnerable RouterOS versions +1. Live Device (SSH) - Sara analyzes the version of the device and determines if it falls into the list of vulnerable versions. +Sara will determine the RouterOS version on the device and compare it with the CVE database. +```bash +~$ sara cve 192.168.88.1 admin +``` -3. If the RouterOS version contains known vulnerabilities, Sara displays a warning indicating: +If necessary, you can specify the SSH key and port: +```bash +~$ sara cve 192.168.88.1 admin ~/.ssh/id_rsa 2222 +``` - - CVE ID; - - Description of the vulnerability and potential risks. +> The password or key passphrase is requested interactively ([getpass](https://docs.python.org/3/library/getpass.html)) -## Specifics of checking +```bash +[+] CVE Audit (Live) + [*] Target Device: 192.168.88.1 + [*] Transport: SSH (port 22) +[?] SSH password for admin@192.168.88.1: + [✓] SSH connection established: admin@192.168.88.1 +[+] Search for CVEs for a specific version +[!] routeros_cves.json not found. +[*] Fetching CVEs from NVD... +[+] Saved 80 CVEs to routeros_cves.json -- Sara does not verify real-world exploitation of vulnerabilities. It only cross-references the RouterOS version against publicly available CVE databases; -- If the device is running an older version of RouterOS, but vulnerable services have been manually disabled, some warnings may be false positives; -- The CVE database is updated over time, so it is recommended to keep an eye out for current patches from MikroTik yourself. +Target RouterOS Version: 7.20.5 Matched CVEs: 0 +CRIT: 0 | HIGH: 0 | MED: 0 | LOW: 0 | UNK: 0 -# How to use +[*] No known CVEs found for this RouterOS version + +[*] Disconnected from RouterOS (192.168.88.1) +``` + +2. Manual + +If the device is unavailable, you can simply specify the desired version: +```bash +~$ sara cve version 7.13.1 +``` + +Sara will perform a vulnerability scan for a specific version without connecting to the device. +```bash +[+] CVE Audit (Manual Version) + [*] RouterOS Version: 7.13.1 +[+] Search for CVEs for a specific version +[!] routeros_cves.json not found. +[*] Fetching CVEs from NVD... +[+] Saved 80 CVEs to routeros_cves.json + +Target RouterOS Version: 7.13.1 Matched CVEs: 2 +CRIT: 0 | HIGH: 1 | MED: 1 | LOW: 0 | UNK: 0 + +CVE ID SEV CVSS PUBLISHED +CVE-2025-6443 HIGH 7.2 2025-06-25 +CVE-2024-54772 MED 5.4 2025-02-11 +``` + +## Features of CVE verification + +Sara does not determine the possibility of actual exploitation of vulnerabilities, but only analyzes the compliance of the RouterOS version with known CVEs; +A vulnerability is considered "relevant" if the device version falls within the range specified in the CVE, or if the vulnerability description contains a recognized version range; +The NVD database often contains incomplete data (`versionStartExcluding=null`, `versionEndExcluding=null`). That's why it's also a good idea to manually check the results against MikroTik's official changelog. + +# How to Use You have two ways to install Sara: @@ -164,201 +146,68 @@ caster@kali:~$ sara -h 2. Manually using Git and Python: ```bash -~$ sudo apt install git python3-colorama python3-netmiko python3-packaging -~$ git clone https://github.com/casterbyte/Sara +~$ sudo apt install git python3-colorama python3-netmiko python3-packaging python3-requests +~$ git clone https://github.com/caster0x00/Sara ~$ cd Sara ~/Sara$ sudo python3 setup.py install ~$ sara -h ``` -## Trigger Arguments (CLI Options) +## Startup -Sara supports the following command line options: +The tool uses subcommands divided by purpose: + +- `audit` - analyze the RouterOS configuration by profiles (system / protocols / wifi); +- `cve` - check RouterOS vulnerabilities based on CVE (live check or manually specified version) ```bash -usage: sara.py [-h] --ip IP --username USERNAME --password PASSWORD [--port PORT] - -options: - -h, --help show this help message and exit - --ip IP The address of your MikroTik router - --username USERNAME SSH username (RO account can be used) - --password PASSWORD SSH password - --port PORT SSH port (default: 22) +~$ sara [...] ``` -1. `--ip` - this argument specifies the IP address of the MikroTik device to which Sara is connecting; +## Authentication -2. `--username` - the SSH username that will be used to connect. Sara supports only authorized access; +Sara does not support passwords in command line arguments. Passwords/passphrases are requested securely via `getpass()` - > You can use read-only (RO) accounts. Sara does not make configuration changes, so you do not need `write` or `full` level access. - -3. `--password` - password for SSH authentication; - -4. `--port` - allows you to specify a non-standard SSH port for connection. The default is **22**, but if you have changed the SSH port number, it must be specified manually. - -# Sara's Launch +## Audit +The RouterOS configuration audit is performed via SSH with selected profiles. ```bash -caster@kali:~$ python3 sara.py --ip 192.168.88.1 --username admin --password mypass +~$ sara audit [key] [port] +~$ sara audit 192.168.88.1 admin system +``` - _____ - / ____| - | (___ __ _ _ __ __ _ - \___ \ / _` | '__/ _` | - ____) | (_| | | | (_| | - |_____/ \__,_|_| \__,_| +With key (SSH): +```bash +~$ sara audit 192.168.88.1 admin system,protocols ~/.ssh/id_rsa +``` - RouterOS Security Inspector. For security engineers - Operates remotely using SSH, designed to evaluate RouterOS security +With a non-standard port: +```bash +~$ sara audit 192.168.88.1 admin system,protocols ~/.ssh/id_rsa 2222 +``` - Author: Magama Bazarov, - Alias: Caster - Version: 1.1 - Codename: Judge - Documentation & Usage: https://github.com/casterbyte/Sara +## Profiles - [!] DISCLAIMER: Use this tool only for auditing your own devices. - [!] Unauthorized use on third-party systems is ILLEGAL. - [!] The author is not responsible for misuse. +Profiles are a comma-separated list: - WARNING: This tool is for security auditing of YOUR OWN RouterOS devices. - Unauthorized use may be illegal. Proceed responsibly. +- `system` refers to system settings, management, users, RMI, SSH security, NAT, scheduler, etc.; +- `protocols` refers to services and network protocols (SMB, UPnP, DNS, SNMP, DDNS, Neighbor Discovery); +- `wifi` refers to security of Wi-Fi interfaces (PMKID, WPS). - Do you wish to proceed? [yes/no]: yes -[*] Connecting to RouterOS at 192.168.88.1:22 -[*] Connection successful! -======================================== -[*] Checking RouterOS Version -[+] Detected RouterOS Version: 7.15.3 -[+] No known CVEs found for this version. -======================================== -[*] Checking SMB Service -[+] SMB is disabled. No risk detected. -[+] No issues found. -======================================== -[*] Checking RMI Services -[!] ALERT: TELNET is ENABLED! This is a high security risk. - - Account passwords can be intercepted -[!] ALERT: FTP is ENABLED! This is a high security risk. - - Are you sure you need FTP? -[!] ALERT: HTTP is ENABLED! This is a high security risk. - - Account passwords can be intercepted -[+] OK: SSH is enabled. Good! - - Are you using strong passwords and SSH keys for authentication? -[!] CAUTION: HTTP-SSL is enabled. - - HTTPS detected. Ensure it uses a valid certificate and strong encryption. -[!] CAUTION: API is enabled. - - RouterOS API is vulnerable to a bruteforce attack. If you need it, make sure you have access to it. -[!] CAUTION: WINBOX is enabled. -[!] CAUTION: If you're using 'Keep Password' in Winbox, your credentials may be stored in plaintext! - - If your PC is compromised, attackers can extract saved credentials. - - Consider disabling 'Keep Password' to improve security. -[!] CAUTION: API-SSL is enabled. - - RouterOS API is vulnerable to a bruteforce attack. If you need it, make sure you have access to it. -======================================== -[*] Checking Default Usernames -[!] CAUTION: Default username 'admin' detected! Change it to a unique one. -[!] CAUTION: Default username 'engineer' detected! Change it to a unique one. -======================================== -[*] Checking network access to RMI -[!] CAUTION: TELNET has no IP restriction set! Please restrict access. -[!] CAUTION: FTP has no IP restriction set! Please restrict access. -[!] CAUTION: WWW has no IP restriction set! Please restrict access. -[+] OK! SSH is restricted to: 192.168.88.0/24 -[!] CAUTION: WWW-SSL has no IP restriction set! Please restrict access. -[!] CAUTION: API has no IP restriction set! Please restrict access. -[+] OK! WINBOX is restricted to: 192.168.88.0/24 -[!] CAUTION: API-SSL has no IP restriction set! Please restrict access. -======================================== -[*] Checking Wi-Fi Security -[+] All Wi-Fi interfaces and security profiles have secure settings. -[*] If you use WPA-PSK or WPA2-PSK, take care of password strength. So that the handshake cannot be easily brute-forced. -[+] No issues found. -======================================== -[*] Checking UPnP Status -[+] UPnP is disabled. No risk detected. -[+] No issues found. -======================================== -[*] Checking DNS Settings -[!] CAUTION: Router is acting as a DNS server! This is just a warning. The DNS port on your RouterOS should not be on the external interface. -======================================== -[*] Checking DDNS Settings -[+] DDNS is disabled. No risk detected. -[+] No issues found. -======================================== -[*] Checking PoE Status -[!] CAUTION: PoE is enabled on ether1. Ensure that connected devices support PoE to prevent damage. -======================================== -[*] Checking RouterBOOT Protection -[!] CAUTION: RouterBOOT protection is disabled! This can allow unauthorized firmware changes and password resets via Netinstall. -======================================== -[*] Checking SOCKS Proxy Status -[+] SOCKS proxy is disabled. No risk detected. -[+] No issues found. -======================================== -[*] Checking Bandwidth Server Status -[+] Bandwidth server is disabled. No risk detected. -[+] No issues found. -======================================== -[*] Checking Neighbor Discovery Protocols -[+] No security risks found in Neighbor Discovery Protocol settings. -[+] No issues found. -======================================== -[*] Checking Password Policy -[!] CAUTION: No minimum password length is enforced! The length of the created passwords must be taken into account. -======================================== -[*] Checking SSH Security -[!] CAUTION: SSH Dynamic Port Forwarding is enabled! This could indicate a RouterOS compromise, and SSH DPF could also be used by an attacker as a pivoting technique. -[!] CAUTION: strong-crypto is disabled! It is recommended to enable it to enhance security. This will: - - Use stronger encryption, HMAC algorithms, and larger DH primes; - - Prefer 256-bit encryption, disable null encryption, prefer SHA-256; - - Disable MD5, use 2048-bit prime for Diffie-Hellman exchange; -======================================== -[*] Checking Connection Tracking -[+] Connection Tracking is properly configured. -[+] No issues found. -======================================== -[*] Checking RoMON Status -[+] RoMON is disabled. No risk detected. -[+] No issues found. -======================================== -[*] Checking Winbox MAC Server Settings -[+] MAC Winbox are properly restricted. -[+] MAC Telnet are properly restricted. -[+] MAC Ping are properly restricted. -======================================== -[*] Checking SNMP Community Strings -[+] SNMP community strings checked. No weak values detected. -[+] No issues found. -======================================== -[*] Checking Firewall NAT Rules -[+] No Destination NAT (dst-nat/netmap) rules detected. No risks found. -[+] No issues found. -======================================== -[*] Checking for Malicious Schedulers -[*] Checking: 'Unknown' → -[+] No malicious schedulers detected. -======================================== -[*] Checking Static DNS Entries -[!] WARNING: The following static DNS entries exist: - - dc01.myownsummer.org → 192.168.88.71 - - fake.example.com → 192.168.88.100 -[*] Were you the one who created those static DNS records? Make sure. -[*] Attackers during RouterOS post-exploitation like to tamper with DNS record settings, for example, for phishing purposes. -======================================== -[*] Checking Router Uptime -[*] Router Uptime: 64 days, 2 hours, 23 minutes - -[*] Disconnected from RouterOS (192.168.88.1:22) -[*] All checks have been completed. Security inspection completed in 3.03 seconds +You can use multiple profiles at once: +```bash +system,protocols,wifi ``` # Copyright -Copyright (c) 2025 Magama Bazarov. This project is licensed under the Apache 2.0 License +Copyright (c) 2026 Mahama Bazarov. +This project is licensed under the Apache 2.0 License. + +This project is not affiliated with or endorsed by SIA Mikrotīkls + +All MikroTik trademarks and product names are the property of their respective owners. # Outro -MikroTik devices are widely used around the world. Sara is designed to help engineers improve security - use it wisely. - -E-mail for contact: magamabazarov@mailbox.org +If you have any suggestions or find any bugs, feel free to create issues in the repository or contact me: [mahamabazarov@mailbox.org](mailto:mahamabazarov@mailbox.org) diff --git a/banner/banner.png b/banner/banner.png deleted file mode 100644 index 9c18be4..0000000 Binary files a/banner/banner.png and /dev/null differ diff --git a/cover/saracover.png b/cover/saracover.png new file mode 100644 index 0000000..675cbc8 Binary files /dev/null and b/cover/saracover.png differ diff --git a/cve_analyzer.py b/cve_analyzer.py new file mode 100644 index 0000000..ae5f040 --- /dev/null +++ b/cve_analyzer.py @@ -0,0 +1,486 @@ +# Auxiliary module cve_analyzer.py for searching CVE using the NIST NVD database +# +# 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 json, re, os, requests, time +from packaging.version import Version, InvalidVersion +from colorama import Fore, Style + +# NVD v2.0 URL and settings +NVD_URL = "https://services.nvd.nist.gov/rest/json/cves/2.0" +KEYWORD = "routeros" +RESULTS_PER_PAGE = 2000 +OUTPUT_FILE = "routeros_cves.json" +GUTTER = 2 # spaces between columns + + +# Simple role-based color mapping +def paint(role: str, text: str) -> str: + role_value = (role or "").lower() + + if role_value in ("crit", "fail"): + return Fore.RED + text + Style.RESET_ALL + if role_value == "warn": + return Fore.YELLOW + text + Style.RESET_ALL + if role_value == "ok": + return Fore.GREEN + text + Style.RESET_ALL + if role_value == "info": + return Fore.CYAN + text + Style.RESET_ALL + if role_value == "label": + return Fore.CYAN + text + Style.RESET_ALL + if role_value == "value": + return Style.BRIGHT + text + Style.RESET_ALL + + return text + + +# ANSI and OSC-8 stripping for width calculation +ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]") +OSC8_BEL = re.compile(r"\x1b]8;;.*?\x07") +OSC8_ST = re.compile(r"\x1b]8;;.*?\x1b\\") + + +def strip_osc8(s: str) -> str: + tmp = OSC8_BEL.sub("", s) + tmp = OSC8_ST.sub("", tmp) + return tmp + + +def visible_len(s: str) -> int: + raw = strip_osc8(s) + no_ansi = ANSI_RE.sub("", raw) + return len(no_ansi) + + +def pad_r(s: str, width: int) -> str: + length = visible_len(s) + pad_len = width - length + if pad_len < 0: + pad_len = 0 + return s + " " * pad_len + + +def pad_l(s: str, width: int) -> str: + length = visible_len(s) + pad_len = width - length + if pad_len < 0: + pad_len = 0 + return " " * pad_len + s + + +# Clickable hyperlink (OSC-8) +def term_link(text: str, url: str) -> str: + return f"\x1b]8;;{url}\x1b\\{text}\x1b]8;;\x1b\\" + + +# Convert RouterOS version string to Version object +def normalize_version(v): + if not v: + return None + try: + return Version(v) + except InvalidVersion: + cleaned = re.sub(r"(rc|beta|testing|stable)[\d\-]*", "", v, flags=re.IGNORECASE) + try: + return Version(cleaned) + except InvalidVersion: + return None + + +# Extract version ranges from CVE description text +def extract_ranges_from_description(description): + description = (description or "").lower() + ranges = [] + + matches = re.findall(r"(?:from\s+)?v?(\d+\.\d+(?:\.\d+)?)\s+to\s+v?(\d+\.\d+(?:\.\d+)?)", description) + for start, end in matches: + ranges.append({"versionStartIncluding": start, "versionEndIncluding": end}) + + matches = re.findall(r"before\s+v?(\d+\.\d+(?:\.\d+)?)", description) + for end in matches: + ranges.append({"versionEndExcluding": end}) + + matches = re.findall(r"after\s+v?(\d+\.\d+(?:\.\d+)?)", description) + for start in matches: + ranges.append({"versionStartExcluding": start}) + + matches = re.findall(r"through\s+v?(\d+\.\d+(?:\.\d+)?)", description) + for end in matches: + ranges.append({"versionEndIncluding": end}) + + matches = re.findall(r"v?(\d+\.\d+)\.x", description) + for base in matches: + ranges.append({"versionStartIncluding": f"{base}.0", "versionEndIncluding": f"{base}.999"}) + + matches = re.findall(r"(?:up to|and below)\s+v?(\d+\.\d+(?:\.\d+)?)", description) + for end in matches: + ranges.append({"versionEndIncluding": end}) + + return ranges + + +# Check if current version falls into a vulnerable range +def is_version_affected(current_v, version_info): + def get(v_key): + return normalize_version(version_info.get(v_key)) + + criteria_raw = version_info.get("criteria", "") or "" + criteria = criteria_raw.lower() + end_excl_raw = version_info.get("versionEndExcluding", "") + + if criteria and ("mikrotik" not in criteria or "routeros" not in criteria): + return False + + if isinstance(end_excl_raw, str) and end_excl_raw.startswith("7") and str(current_v).startswith("6."): + return False + if isinstance(end_excl_raw, str) and end_excl_raw.startswith("6") and str(current_v).startswith("7."): + return False + + start_incl = get("versionStartIncluding") + start_excl = get("versionStartExcluding") + end_incl = get("versionEndIncluding") + end_excl = get("versionEndExcluding") + + if not any([start_incl, start_excl, end_incl, end_excl]): + version_match = re.search(r"routeros:([\w.\-]+)", criteria_raw) + if version_match: + version_exact = normalize_version(version_match.group(1)) + return version_exact is not None and current_v == version_exact + return False + + for raw_key, normed in zip( + ["versionStartIncluding", "versionStartExcluding", "versionEndIncluding", "versionEndExcluding"], + [start_incl, start_excl, end_incl, end_excl], + ): + if version_info.get(raw_key) and normed is None: + return False + + if start_incl and current_v < start_incl: + return False + if start_excl and current_v <= start_excl: + return False + if end_incl and current_v > end_incl: + return False + if end_excl and current_v >= end_excl: + return False + + return True + + +# Download all RouterOS CVEs from NVD and save locally +def fetch_all_cves(): + all_cves = [] + start_index = 0 + + print(Fore.CYAN + "[*] Fetching CVEs from NVD...") + while True: + params = { + "keywordSearch": KEYWORD, + "startIndex": start_index, + "resultsPerPage": RESULTS_PER_PAGE, + } + try: + response = requests.get(NVD_URL, params=params, timeout=30) + response.raise_for_status() + data = response.json() + except requests.exceptions.RequestException as e: + print(Fore.RED + f"[-] HTTP Error: {e}") + break + except json.JSONDecodeError: + print(Fore.RED + "[-] Failed to parse JSON from NVD.") + break + + cve_items = data.get("vulnerabilities", []) + total_results = data.get("totalResults", 0) + + for item in cve_items: + cve = item.get("cve", {}) + cve_id = cve.get("id") + description = next((d["value"] for d in cve.get("descriptions", []) if d.get("lang") == "en"), "") + severity = "UNKNOWN" + score = "N/A" + published = cve.get("published", "") + + metrics = cve.get("metrics", {}) + if "cvssMetricV31" in metrics: + cvss = metrics["cvssMetricV31"][0]["cvssData"] + severity = cvss.get("baseSeverity", "UNKNOWN") + score = cvss.get("baseScore", "N/A") + elif "cvssMetricV30" in metrics: + cvss = metrics["cvssMetricV30"][0]["cvssData"] + severity = cvss.get("baseSeverity", "UNKNOWN") + score = cvss.get("baseScore", "N/A") + + affected_versions = [] + for config in cve.get("configurations", []): + for node in config.get("nodes", []): + for match in node.get("cpeMatch", []): + if not match.get("vulnerable", False): + continue + criteria = match.get("criteria", "") or "" + crit_l = criteria.lower() + if "mikrotik" not in crit_l or "routeros" not in crit_l: + continue + affected_versions.append( + { + "criteria": criteria, + "versionStartIncluding": match.get("versionStartIncluding"), + "versionStartExcluding": match.get("versionStartExcluding"), + "versionEndIncluding": match.get("versionEndIncluding"), + "versionEndExcluding": match.get("versionEndExcluding"), + } + ) + + all_cves.append( + { + "cve_id": cve_id, + "description": description, + "severity": severity, + "cvss_score": score, + "published": published, + "affected_versions": affected_versions, + } + ) + + start_index += RESULTS_PER_PAGE + if start_index >= total_results: + break + time.sleep(1.5) + + with open(OUTPUT_FILE, "w", encoding="utf-8") as f: + json.dump(all_cves, f, indent=2, ensure_ascii=False) + print(Fore.GREEN + f"[+] Saved {len(all_cves)} CVEs to {OUTPUT_FILE}") + + +# Load local CVE cache and optionally refresh it +def load_cve_data(): + if not os.path.isfile(OUTPUT_FILE): + print(Fore.YELLOW + f"[!] {OUTPUT_FILE} not found.") + fetch_all_cves() + else: + print(Fore.YELLOW + f"[?] {OUTPUT_FILE} already exists.") + answer = input(Fore.YELLOW + " Overwrite it with fresh CVE data? [yes/no]: ").strip().lower() + if answer == "yes": + fetch_all_cves() + + try: + with open(OUTPUT_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(Fore.RED + f"[-] Failed to load {OUTPUT_FILE}: {e}") + return None + + +# Format CVSS score as 0.1 or N/A +def fmt_cvss(x): + try: + value = float(x) + return f"{value:0.1f}" + except Exception: + return "N/A" + + +# Severity rank for sorting +sev_rank = { + "CRITICAL": 0, + "HIGH": 1, + "MEDIUM": 2, + "LOW": 3, + "UNKNOWN": 4, +} + + +def key_tuple(m): + sev = m.get("severity", "UNKNOWN") or "UNKNOWN" + sev_u = sev.upper() + rank = sev_rank.get(sev_u, 4) + pub = m.get("published") or "" + return (rank, pub) + + +# Severity tag (CRIT/HIGH/MED/LOW/UNK) +def sev_tag(sev: str): + sev_u = (sev or "UNKNOWN").upper() + if sev_u == "CRITICAL": + return paint("crit", "CRIT") + if sev_u == "HIGH": + return paint("fail", "HIGH") + if sev_u == "MEDIUM": + return paint("warn", "MED") + if sev_u == "LOW": + return paint("info", "LOW") + return paint("value", "UNK") + + +def count_seg(label: str, n: int): + role_map = { + "CRIT": "crit", + "HIGH": "fail", + "MED": "warn", + "LOW": "info", + "UNK": "value", + } + role = role_map[label] + return paint(role, label) + ": " + paint("value", str(n)) + + +# Summary header +def render_summary(version: str, matches, counters): + c_crit = counters.get("CRITICAL", 0) + c_high = counters.get("HIGH", 0) + c_med = counters.get("MEDIUM", 0) + c_low = counters.get("LOW", 0) + c_unk = counters.get("UNKNOWN", 0) + + line1 = ( + paint("label", "Target RouterOS Version:") + " " + paint("value", version) + + " " + + paint("label", "Matched CVEs:") + " " + paint("value", str(len(matches))) + ) + + parts = [ + count_seg("CRIT", c_crit), + count_seg("HIGH", c_high), + count_seg("MED", c_med), + count_seg("LOW", c_low), + count_seg("UNK", c_unk), + ] + line2 = " | ".join(parts) + + print(line1) + print(line2) + print() + + +# Table rendering +def render_cve(rows): + max_id_len = 14 + for m in rows: + cve_id = m.get("cve_id", "") or "" + length = len(cve_id) + if length > max_id_len: + max_id_len = length + + id_w = max_id_len + 2 + if id_w < 16: + id_w = 16 + if id_w > 22: + id_w = 22 + + sev_w = 5 + cvss_w = 4 + pub_w = 10 + sep = " " * GUTTER + + head = ( + pad_r(paint("label", "CVE ID"), id_w) + + sep + + pad_r(paint("label", "SEV"), sev_w) + + sep + + pad_r(paint("label", "CVSS"), cvss_w) + + sep + + pad_r(paint("label", "PUBLISHED"), pub_w) + ) + print(head) + + for m in rows: + cve_id = m.get("cve_id", "") or "" + link = term_link(cve_id, "https://nvd.nist.gov/vuln/detail/" + cve_id) + + sev_t = sev_tag(m.get("severity", "UNKNOWN")) + cvss = fmt_cvss(m.get("cvss_score", "N/A")) + + published = m.get("published") or "-" + published = str(published)[:10] + + line = ( + pad_r(paint("info", link), id_w) + + sep + + pad_r(sev_t, sev_w) + + sep + + pad_l(paint("value", cvss), cvss_w) + + sep + + pad_r(paint("value", published), pub_w) + ) + print(line) + + +# Core CVE matching logic for a given RouterOS version +def run_cve_match_for_version(current_v, current_version: str): + cve_data = load_cve_data() + if not cve_data: + return + + counters = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0, "UNKNOWN": 0} + matches = [] + + for cve in cve_data: + matched = False + affected_versions = cve.get("affected_versions", []) + + if not affected_versions: + affected_versions = extract_ranges_from_description(cve.get("description", "")) + + for version_info in affected_versions: + if is_version_affected(current_v, version_info): + matched = True + break + + if not matched: + continue + + sev = (cve.get("severity", "UNKNOWN") or "UNKNOWN").upper() + counters[sev] = counters.get(sev, 0) + 1 + + matches.append( + { + "cve_id": cve.get("cve_id", ""), + "severity": sev, + "cvss_score": cve.get("cvss_score", "N/A"), + "published": cve.get("published", ""), + } + ) + + print() + render_summary(current_version, matches, counters) + + if not matches: + print(paint("ok", "[*] No known CVEs found for this RouterOS version")) + print() + return + + rows = sorted(matches, key=key_tuple) + render_cve(rows) + + +# Live CVE audit based on device version (kept for potential reuse) +def run_cve_audit(connection): + print(paint("label", "[+] Search for CVEs for a specific version")) + output = connection.send_command("/system resource print") + match = re.search(r"version:\s*([\w.\-]+)", output) + if not match: + print(Fore.RED + "[-] ERROR: Could not determine RouterOS version.") + return + + current_version = match.group(1) + current_v = normalize_version(current_version) + if not current_v: + print(Fore.RED + f"[-] ERROR: RouterOS version '{current_version}' is invalid.") + return + + run_cve_match_for_version(current_v, current_version) + + + +# CVE audit for manually provided RouterOS version string (used by Sara) +def run_cve_audit_for_version(version_str: str): + print(paint("label", "[+] Search for CVEs for a specific version")) + current_version = version_str.strip() + current_v = normalize_version(current_version) + if not current_v: + print(Fore.RED + f"[-] ERROR: RouterOS version '{current_version}' is invalid.") + return + + run_cve_match_for_version(current_v, current_version) diff --git a/cve_lookup.py b/cve_lookup.py deleted file mode 100644 index c8827db..0000000 --- a/cve_lookup.py +++ /dev/null @@ -1,72 +0,0 @@ -# Sara's helper module for CVE search based on RouterOS version analysis -# Downloaded and adapted from: https://cve.mitre.org/data/downloads -# The CVE search thanks to this module is passive and does not involve sending various payloads, launching exploits and so on - -# UPD: It's not the best realization at this point. I need to use the NIST NVD database without having to hardcode CVEs. This was not the best solution. - -cve_routeros_database = { - "CVE-2008-0680": "SNMPd in MikroTik RouterOS 3.2 and earlier allows remote attackers to cause a denial of service (daemon crash) via a crafted SNMP SET request.", - "CVE-2008-6976": "MikroTik RouterOS 3.x through 3.13 and 2.x through 2.9.51 allows remote attackers to modify Network Management System (NMS) settings via a crafted SNMP set request.", - "CVE-2012-6050": "The winbox service in MikroTik RouterOS 5.15 and earlier allows remote attackers to cause a denial of service (CPU consumption), read the router version, and possibly have other impacts via a request to download the router's DLLs or plugins, as demonstrated by roteros.dll", - "CVE-2015-2350": "Cross-site request forgery (CSRF) vulnerability in MikroTik RouterOS 5.0 and earlier allows remote attackers to hijack the authentication of administrators for requests that change the administrator password via a request in the status page to /cfg.", - "CVE-2017-17537": "MikroTik RouterBOARD v6.39.2 and v6.40.5 allows an unauthenticated remote attacker to cause a denial of service by connecting to TCP port 53 and sending data that begins with many '\0' characters", - "CVE-2017-17538": "MikroTik v6.40.5 devices allow remote attackers to cause a denial of service via a flood of ICMP packets.", - "CVE-2017-6297": "The L2TP Client in MikroTik RouterOS versions 6.83.3 and 6.37.4 does not enable IPsec encryption after a reboot, which allows man-in-the-middle attackers to view transmitted data unencrypted and gain access to networks on the L2TP server by monitoring the packets for the transmitted data and obtaining the L2TP secret", - "CVE-2017-6444": "The MikroTik Router hAP Lite 6.25 has no protection mechanism for unsolicited TCP ACK packets in the case of a fast network connection", - "CVE-2017-7285": "A vulnerability in the network stack of MikroTik Version 6.38.5 released 2017-03-09 could allow an unauthenticated remote attacker to exhaust all available CPU via a flood of TCP RST packets", - "CVE-2017-8338": "A vulnerability in MikroTik Version 6.38.5 could allow an unauthenticated remote attacker to exhaust all available CPU via a flood of UDP packets on port 500 (used for L2TP over IPsec)", - "CVE-2018-10066": "An issue was discovered in MikroTik RouterOS 6.41.4. Missing OpenVPN server certificate verification allows a remote unauthenticated attacker capable of intercepting client traffic to act as a malicious OpenVPN server. This may allow the attacker to gain access to the client's internal network", - "CVE-2018-10070": "A vulnerability in MikroTik Version 6.41.4 could allow an unauthenticated remote attacker to exhaust all available CPU and all available RAM by sending a crafted FTP request on port 21 that begins with many '\0' characters", - "CVE-2018-1157": "Mikrotik RouterOS before 6.42.7 and 6.40.9 is vulnerable to a memory exhaustion vulnerability. An authenticated remote attacker can crash the HTTP server and in some circumstances reboot the system via a crafted HTTP POST request.", - "CVE-2018-1158": "Mikrotik RouterOS before 6.42.7 and 6.40.9 is vulnerable to a stack exhaustion vulnerability. An authenticated remote attacker can crash the HTTP server via recursive parsing of JSON.", - "CVE-2018-14847": "MikroTik RouterOS through 6.42 allows unauthenticated remote attackers to read arbitrary files and remote authenticated attackers to write arbitrary files due to a directory traversal vulnerability in the WinBox interface.", - "CVE-2018-7445": "A buffer overflow was found in the MikroTik RouterOS SMB service when processing NetBIOS session request messages. Remote attackers with access to the service can exploit this vulnerability and gain code execution on the system. The overflow occurs before authentication takes place", - "CVE-2019-13074": "A vulnerability in the FTP daemon on MikroTik routers through 6.44.3 could allow remote attackers to exhaust all available memory, causing the device to reboot because of uncontrolled resource management.", - "CVE-2019-15055": "MikroTik RouterOS through 6.44.5 and 6.45.x through 6.45.3 improperly handles the disk name, which allows authenticated users to delete arbitrary files. Attackers can exploit this vulnerability to reset credential storage, which allows them access to the management interface as an administrator without authentication", - "CVE-2019-16160": "An integer underflow in the SMB server of MikroTik RouterOS before 6.45.5 allows remote unauthenticated attackers to crash the service.", - "CVE-2019-3924": "MikroTik RouterOS before 6.43.12 (stable) and 6.42.12 (long-term) is vulnerable to an intermediary vulnerability. The software will execute user defined network requests to both WAN and LAN clients. A remote unauthenticated attacker can use this vulnerability to bypass the router's firewall or for general network scanning activities.", - "CVE-2019-3943": "MikroTik RouterOS versions Stable 6.43.12 and below, Long-term 6.42.12 and below, and Testing 6.44beta75 and below are vulnerable to an authenticated, remote directory traversal via the HTTP or Winbox interfaces. An authenticated, remote attack can use this vulnerability to read and write files outside of the sandbox directory (/rw/disk)", - "CVE-2019-3978": "RouterOS versions 6.45.6 Stable, 6.44.5 Long-term, and below allow remote unauthenticated attackers to trigger DNS queries via port 8291. The queries are sent from the router to a server of the attacker's choice. The DNS responses are cached by the router, potentially resulting in cache poisoning", - "CVE-2019-3981": "MikroTik Winbox 3.20 and below is vulnerable to man in the middle attacks. A man in the middle can downgrade the client's authentication protocol and recover the user's username and MD5 hashed password.", - "CVE-2020-10364": "The SSH daemon on MikroTik routers through 6.44.3 could allow remote attackers to generate CPU activity, trigger refusal of new authorized connections, and cause a reboot via connect and write system calls, because of uncontrolled resource management", - "CVE-2020-11881": "An array index error in MikroTik RouterOS 6.41.3 through 6.46.5, and 7.x through 7.0 Beta5, allows an unauthenticated remote attacker to crash the SMB server via modified setup-request packets,", - "CVE-2020-20021": "An issue discovered in MikroTik Router 6.46.3 and earlier allows attacker to cause denial of service via misconfiguration in the SSH daemon.", - "CVE-2020-20214": "MikroTik RouterOS 6.44.6 (long-term tree) suffers from an assertion failure vulnerability in the btest process. An authenticated remote attacker can cause a Denial of Service due to an assertion failure via a crafted packet.", - "CVE-2020-20217": "MikroTik RouterOS before 6.47 (stable tree) suffers from an uncontrolled resource consumption vulnerability in the /nova/bin/route process. An authenticated remote attacker can cause a Denial of Service due to overloading the systems CPU.", - "CVE-2020-20220": "MikroTik RouterOS prior to stable 6.47 suffers from a memory corruption vulnerability in the /nova/bin/bfd process. An authenticated remote attacker can cause a Denial of Service (NULL pointer dereference).", - "CVE-2020-20222": "MikroTik RouterOS 6.44.6 (long-term tree) suffers from a memory corruption vulnerability in the /nova/bin/sniffer process. An authenticated remote attacker can cause a Denial of Service (NULL pointer dereference).", - "CVE-2020-20225": "MikroTik RouterOS before 6.47 (stable tree) suffers from an assertion failure vulnerability in the /nova/bin/user process. An authenticated remote attacker can cause a Denial of Service due to an assertion failure via a crafted packet.", - "CVE-2020-20227": "MikroTik RouterOS stable 6.47 suffers from a memory corruption vulnerability in the /nova/bin/diskd process. An authenticated remote attacker can cause a Denial of Service due to invalid memory access.", - "CVE-2020-20230": "MikroTik RouterOS before stable 6.47 suffers from an uncontrolled resource consumption in the sshd process. An authenticated remote attacker can cause a Denial of Service due to overloading the systems CPU.", - "CVE-2020-20231": "MikroTik RouterOS through stable version 6.48.3 suffers from a memory corruption vulnerability in the /nova/bin/detnet process. An authenticated remote attacker can cause a Denial of Service (NULL pointer dereference).", - "CVE-2020-20236": "MikroTik RouterOS 6.46.3 (stable tree) suffers from a memory corruption vulnerability in the /nova/bin/sniffer process. An authenticated remote attacker can cause a Denial of Service due to improper memory access.", - "CVE-2020-20237": "Mikrotik RouterOS 6.46.3 (stable tree) suffers from a memory corruption vulnerability in the /nova/bin/sniffer process. An authenticated remote attacker can cause a Denial of Service due to improper memory access.", - "CVE-2020-20245": "Mikrotik RouterOS stable 6.46.3 suffers from a memory corruption vulnerability in the log process. An authenticated remote attacker can cause a Denial of Service due to improper memory access.", - "CVE-2020-20246": "Mikrotik RouterOS stable 6.46.3 suffers from a memory corruption vulnerability in the mactel process. An authenticated remote attacker can cause a Denial of Service due to improper memory access.", - "CVE-2020-20248": "Mikrotik RouterOS before stable 6.47 suffers from an uncontrolled resource consumption in the memtest process. An authenticated remote attacker can cause a Denial of Service due to overloading the systems CPU.", - "CVE-2020-20249": "Mikrotik RouterOS before stable 6.47 suffers from a memory corruption vulnerability in the resolver process. By sending a crafted packet", - "CVE-2020-20250": "Mikrotik RouterOS before stable version 6.47 suffers from a memory corruption vulnerability in the /nova/bin/lcdstat process. An authenticated remote attacker can cause a Denial of Service (NULL pointer dereference) NOTE: this is different from CVE-2020-20253 and CVE-2020-20254. All four vulnerabilities in the /nova/bin/lcdstat process are discussed in the CVE-2020-20250", - "CVE-2020-20252": "Mikrotik RouterOS before stable version 6.47 suffers from a memory corruption vulnerability in the /nova/bin/lcdstat process. An authenticated remote attacker can cause a Denial of Service (NULL pointer dereference)", - "CVE-2020-20253": "Mikrotik RouterOS before 6.47 (stable tree) suffers from a divison by zero vulnerability in the /nova/bin/lcdstat process. An authenticated remote attacker can cause a Denial of Service due to a divide by zero error.", - "CVE-2020-20254": "Mikrotik RouterOS before 6.47 (stable tree) suffers from a memory corruption vulnerability in the /nova/bin/lcdstat process. An authenticated remote attacker can cause a Denial of Service (NULL pointer dereference).", - "CVE-2020-20262": "Mikrotik RouterOS before 6.47 (stable tree) suffers from an assertion failure vulnerability in the /ram/pckg/security/nova/bin/ipsec process. An authenticated remote attacker can cause a Denial of Service due to an assertion failure via a crafted packet.", - "CVE-2020-20264": "Mikrotik RouterOS before 6.47 (stable tree) in the /ram/pckg/advanced-tools/nova/bin/netwatch process. An authenticated remote attacker can cause a Denial of Service due to a divide by zero error.", - "CVE-2020-20265": "Mikrotik RouterOS before 6.47 (stable tree) suffers from a memory corruption vulnerability in the /ram/pckg/wireless/nova/bin/wireless process. An authenticated remote attacker can cause a Denial of Service due via a crafted packet.", - "CVE-2020-20266": "Mikrotik RouterOS before 6.47 (stable tree) suffers from a memory corruption vulnerability in the /nova/bin/dot1x process. An authenticated remote attacker can cause a Denial of Service (NULL pointer dereference).", - "CVE-2020-5720": "MikroTik WinBox before 3.21 is vulnerable to a path traversal vulnerability that allows creation of arbitrary files wherevere WinBox has write permissions. WinBox is vulnerable to this attack if it connects to a malicious endpoint or if an attacker mounts a man in the middle attack.", - "CVE-2020-5721": "MikroTik WinBox 3.22 and below stores the user's cleartext password in the settings.cfg.viw configuration file when the Keep Password field is set and no Master Password is set. Keep Password is set by default and", - "CVE-2021-27221": "MikroTik RouterOS 6.47.9 allows remote authenticated ftp users to create or overwrite arbitrary .rsc files via the /export command. NOTE: the vendor's position is that this is intended behavior because of how user policies work.", - "CVE-2021-3014": "MikroTik RouterOS through 6.48 is vulnerable to XSS in the hotspot login page via the target parameter", - "CVE-2021-36613": "MikroTik RouterOS before stable 6.48.2 suffers from a memory corruption vulnerability in the ptp process. An authenticated remote attacker can cause a Denial of Service (NULL pointer dereference)", - "CVE-2021-36614": "MikroTik RouterOS before stable 6.48.2 suffers from a memory corruption vulnerability in the tr069-client process. An authenticated remote attacker can cause a Denial of Service (NULL pointer dereference)", - "CVE-2022-34960": "The container package in MikroTik RouterOS 7.4beta4 allows an attacker to create mount points pointing to symbolic links", - "CVE-2022-36522": "Mikrotik RouterOS through stable 6.48.3 was discovered to contain an assertion failure in the component /advanced-tools/nova/bin/netwatch. This vulnerability allows attackers to cause a Denial of Service (DoS) via a crafted packet.", - "CVE-2022-45313": "Mikrotik RouterOS before stable 7.5 was discovered to contain an out-of-bounds read in the hotspot process. This vulnerability allows attackers to execute arbitrary code via a crafted nova message.", - "CVE-2022-45315": "Mikrotik RouterOS before stable 7.6 was discovered to contain an out-of-bounds read in the snmp process. This vulnerability allows attackers to execute arbitrary code via a crafted packet.", - "CVE-2023-24094": "An issue in the bridge2 component of MikroTik RouterOS v6.40.5 allows attackers to cause a Denial of Service (DoS) via crafted packets.", - "CVE-2023-30799": "MikroTik RouterOS stable before 6.49.7 and long-term through 6.48.6 are vulnerable to a privilege escalation issue. A remote and authenticated attacker can escalate privileges from admin to super-admin on the Winbox or HTTP interface. The attacker can abuse this vulnerability to execute arbitrary code on the system.", - "CVE-2023-30800": "The web server used by MikroTik RouterOS version 6 is affected by a heap memory corruption issue. A remote and unauthenticated attacker can corrupt the server's heap memory by sending a crafted HTTP request. As a result", - "CVE-2023-41570": "MikroTik RouterOS v7.1 to 7.11 was discovered to contain incorrect access control mechanisms in place for the Rest API.", - "CVE-2024-38861": "Improper Certificate Validation in Checkmk Exchange plugin MikroTik allows attackers in MitM position to intercept traffic. This issue affects MikroTik: from 2.0.0 through 2.5.5, from 0.4a_mk through 2.0a.", - "CVE-2024-54772": "An issue was discovered in the Winbox service of MikroTik RouterOS long-term release v6.43.13 through v6.49.13 and stable v6.43 through v7.17.2. A patch is available in the stable release v6.49.18. A discrepancy in response size between connection attempts made with a valid username and those with an invalid username allows attackers to enumerate for valid accounts.", -} diff --git a/sara.py b/sara.py index 240753b..15b54b8 100644 --- a/sara.py +++ b/sara.py @@ -1,609 +1,662 @@ #!/usr/bin/env python3 -# Copyright (c) 2025 Magama Bazarov +# 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 -# Connecting required libraries and cve_lookup module import argparse import colorama -import time import re import sys +import os +from getpass import getpass from netmiko import ConnectHandler from colorama import Fore, Style -from cve_lookup import cve_routeros_database from packaging.version import Version +from cve_analyzer import run_cve_audit, run_cve_audit_for_version -# Initialize colorama for colored console output +# init colors colorama.init(autoreset=True) +INDENT = " " + + +# print banner def banner(): - banner_text = r""" - _____ - / ____| - | (___ __ _ _ __ __ _ - \___ \ / _` | '__/ _` | - ____) | (_| | | | (_| | - |_____/ \__,_|_| \__,_| + banner_art = r""" + _____ + / ___/____ __________ _ + \__ \/ __ `/ ___/ __ `/ + ___/ / /_/ / / / /_/ / + /____/\__,_/_/ \__,_/ """ - # Display the program banner and metadata - print(banner_text) - print(" " + Fore.YELLOW + "RouterOS Security Inspector. For security engineers") - print(" " + Fore.YELLOW + "Operates remotely using SSH, designed to evaluate RouterOS security\n") - print(" " + Fore.YELLOW + "Author: " + Style.RESET_ALL + "Magama Bazarov, ") - print(" " + Fore.YELLOW + "Alias: " + Style.RESET_ALL + "Caster") - print(" " + Fore.YELLOW + "Version: " + Style.RESET_ALL + "1.1.0") - print(" " + Fore.YELLOW + "Codename: " + Style.RESET_ALL + "Judge") - print(" " + Fore.YELLOW + "Documentation & Usage: " + Style.RESET_ALL + "https://github.com/casterbyte/Sara") - print() + 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") - # Display a legal disclaimer to emphasize responsible usage - print(" " + Fore.YELLOW + "[!] DISCLAIMER: Use this tool only for auditing your own devices.") - print(" " + Fore.YELLOW + "[!] Unauthorized use on third-party systems is ILLEGAL.") - print(" " + Fore.YELLOW + "[!] The author is not responsible for misuse.") - print() -# Establish SSH connection to the RouterOS device using Netmiko -def connect_to_router(ip, username, password, port): +# 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": username, - "password": password, + "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: - print(Fore.GREEN + Style.BRIGHT + f"[*] Connecting to RouterOS at {ip}:{port}") - connection = ConnectHandler(**device) - print(Fore.WHITE + "[*] Connection successful!") - return connection + conn = ConnectHandler(**device) + ok(f"SSH connection established: {user}@{ip}") + return conn except Exception as e: - print(Fore.RED + f"[-] Connection failed: {e}") - exit(1) + error(f"SSH connection failed: {e}") + sys.exit(1) -# Print a visual separator for better readability in the output -def separator(title): - print(Fore.WHITE + Style.BRIGHT + '=' * 50) - print(Fore.WHITE + Style.BRIGHT + f"[*] {title}") +# 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): - # Parses a version string into a comparable Version object. Example: "6.49.7" → Version(6.49.7) return Version(version_str) -def extract_version_from_cve(description): - # Case: "X.Y to Z.W" - range_match = re.search(r"v?(\d+\.\d+(?:\.\d+)?)\s*to\s*v?(\d+\.\d+(?:\.\d+)?)", description, re.IGNORECASE) - if range_match: - start_version, end_version = range_match.groups() - return "range", parse_version(start_version), parse_version(end_version) - # Case: "before X.Y.Z", "after X.Y.Z", "through X.Y.Z", "and below X.Y.Z" - keyword_match = re.search(r"(before|through|after|and below)?\s*v?(\d+\.\d+(?:\.\d+)?)", description, re.IGNORECASE) - if keyword_match: - keyword, version = keyword_match.groups() - return keyword, None, parse_version(version) - - # Case: "6.49.x" (Wildcard notation) - wildcard_match = re.search(r"v?(\d+\.\d+)\.x", description, re.IGNORECASE) - if wildcard_match: - base_version = wildcard_match.group(1) # Example: "6.49" - return "before", None, parse_version(base_version + ".999") # Treat as "6.49.999" for comparison - - return None, None, None - -# Retrieves the RouterOS version and checks for known CVEs +# detect and print RouterOS version def check_routeros_version(connection): - # Separator outlet - separator("Checking RouterOS Version") - command = "/system resource print" - output = connection.send_command(command) - + # 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)) - print(Fore.GREEN + f"[+] Detected RouterOS Version: {routeros_version}") - - found_cves = [] - - for cve, description in cve_routeros_database.items(): - keyword, start_version, end_version = extract_version_from_cve(description) - - if keyword == "range" and start_version and end_version: - if start_version <= routeros_version <= end_version: - found_cves.append((cve, description)) - - elif keyword and end_version: - if keyword == "before" and routeros_version < end_version: - found_cves.append((cve, description)) - elif keyword == "through" and routeros_version <= end_version: - found_cves.append((cve, description)) - elif keyword == "after" and routeros_version > end_version: - found_cves.append((cve, description)) - elif keyword == "and below" and routeros_version <= end_version: - found_cves.append((cve, description)) - - # Direct version match - elif str(routeros_version) in description: - found_cves.append((cve, description)) - - if found_cves: - print(Fore.YELLOW + f"[!] CAUTION: Found {len(found_cves)} CVEs affecting RouterOS {routeros_version}!") - for cve, description in found_cves: - print(Fore.RED + f" - {cve}: {description}") - else: - print(Fore.GREEN + "[+] No known CVEs found for this version.") + info(f"Detected RouterOS: {Fore.MAGENTA}{routeros_version}{Style.RESET_ALL}") else: - print(Fore.RED + Style.BRIGHT + "[-] ERROR: Could not determine RouterOS version.") + error("Could not determine RouterOS version") -# Check if SMB service is enabled (potential security risk) + +# SMB service check def check_smb(connection): - # Separator outlet - separator("Checking SMB Service") - command = "/ip smb print" - output = connection.send_command(command) - + section("SMB Service") + output = connection.send_command("/ip smb print") if "enabled: yes" in output: - print(Fore.RED + Style.BRIGHT + "[*] CAUTION: SMB service is enabled! Did you turn it on? Do you need SMB? Also avoid CVE-2018-7445") + alert("SMB service is enabled! Do you need SMB? Also avoid CVE-2018-7445") else: - print(Fore.GREEN + "[+] SMB is disabled. No risk detected.") - print(Fore.GREEN + "[+] No issues found.") - -# Check for high-risk remote management interfaces (RMI) + ok("SMB is disabled") + ok("No issues detected") + + +# RMI services exposure check def check_rmi_services(connection): - # Separator outlet - separator("Checking RMI Services") - command = "/ip service print" - output = connection.send_command(command) - + 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"] - safe = ["ssh"] - + 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 re.search(r"^\d+\s+X", line): - continue - match = re.search(r"(\S+)\s+\d+", line) - if match: - service_name = match.group(1).lower() - display_name = service_name.upper().replace("WWW", "HTTP").replace("WWW-SSL", "HTTPS") + 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 - if service_name in high_risk: - print(Fore.RED + Style.BRIGHT + f"[!] ALERT: {display_name} is ENABLED! This is a high security risk.") - if service_name == "ftp": - print(Fore.RED + " - Are you sure you need FTP?") - if service_name == "telnet": - print(Fore.RED + " - Account passwords can be intercepted") - if service_name == "www": - print(Fore.RED + " - Account passwords can be intercepted") - risks_found = True + match = re.search(r"(\S+)\s+\d+", line) + if not match: + continue - elif service_name in moderate_risk: - print(Fore.YELLOW + Style.BRIGHT + f"[!] CAUTION: {display_name} is enabled.") - if service_name in ["api", "api-ssl"]: - print(Fore.YELLOW + " - RouterOS API is vulnerable to a bruteforce attack. If you need it, make sure you have access to it.") - elif service_name == "www-ssl": - print(Fore.GREEN + " - HTTPS detected. Ensure it uses a valid certificate and strong encryption.") - elif service_name == "winbox": - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: If you're using 'Keep Password' in Winbox, your credentials may be stored in plaintext!") - print(Fore.YELLOW + " - If your PC is compromised, attackers can extract saved credentials.") - print(Fore.YELLOW + " - Consider disabling 'Keep Password' to improve security.") - - elif service_name in safe: - print(Fore.GREEN + f"[+] OK: {display_name} is enabled. Good!") - print(Fore.GREEN + " - Are you using strong passwords and SSH keys for authentication?") + 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: - print(Fore.GREEN + "[+] No high-risk RMI services enabled.") - print(Fore.GREEN + "[+] No issues found.") + ok("No high-risk RMI services detected") -# Check for default usernames that could be security risks + +# default usernames check def check_default_users(connection): - # Separator outlet - separator("Checking Default Usernames") - command = "/user print detail" - output = connection.send_command(command) - + section("Default Usernames") + output = connection.send_command("/user print detail") default_users = {"admin", "engineer", "user", "test", "root", "mikrotik", "routeros"} risks_found = False - for line in output.split("\n\n"): - match = re.search(r"name=\"?(\w+)\"?", line) - if match: - username = match.group(1).lower() - if username in default_users: - print(Fore.YELLOW + Style.BRIGHT + f"[!] CAUTION: Default username '{username}' detected! Change it to a unique one.") - risks_found = True - if not risks_found: - print(Fore.GREEN + "[+] No default usernames found.") - -# Verify whether critical services have restricted network access -def checking_access_to_RMI(connection): - # Separator outlet - separator("Checking network access to RMI") - command = "/ip service print detail" - output = connection.send_command(command) - - risks_found = False - - for line in output.split("\n\n"): - service_match = re.search(r'name="([^"]+)"', line) - address_match = re.search(r'address=([\d./,]+)', line) - - if service_match: - service_name = service_match.group(1) - - if address_match: - address_list = address_match.group(1).split(",") - if not address_list or address_list == [""] or "0.0.0.0/0" in address_list: - print(Fore.YELLOW + Style.BRIGHT + f"[!] CAUTION: {service_name.upper()} is exposed to the entire network! Restrict access to trusted IP ranges.") - risks_found = True - else: - print(Fore.GREEN + f"[+] OK! {service_name.upper()} is restricted to: {', '.join(address_list)}") - else: - print(Fore.YELLOW + Style.BRIGHT + f"[!] CAUTION: {service_name.upper()} has no IP restriction set! Please restrict access.") - risks_found = True - - if not risks_found: - print(Fore.GREEN + "[+] All services have proper IP restrictions.") - -# Analyze Wi-Fi security settings, including WPS and PMKID vulnerabilities -# I think this is the most unstable feature of the whole Sara, need more feedback from users to get it perfect -def check_wifi_security(connection): - # Separator outlet - separator("Checking WLAN Security") - risks_found = False - try: - # Retrieve RouterOS version to determine supported commands - command = "/system resource print" - output = connection.send_command(command) - version_match = re.search(r"version:\s*([\d.]+)", output) - routeros_version = Version(version_match.group(1)) if version_match else Version("0.0.0") - - # Wi-Fi (ROS v6/v7) - commands = ["/interface wifi print detail", "/interface wireless print detail"] - found_valid_output = False - - for command in commands: - output = connection.send_command(command) - if "bad command name" not in output.lower() and output.strip(): - found_valid_output = True - interfaces = output.split("\n\n") - for interface in interfaces: - name_match = re.search(r'name="([^"]+)"', interface) - default_name_match = re.search(r'default-name="([^"]+)"', interface) - pmkid_match = re.search(r'disable-pmkid=(\S+)', interface) - wps_match = re.search(r'wps=(\S+)', interface) - - name = name_match.group(1) if name_match else (default_name_match.group(1) if default_name_match else "Unknown") - pmkid = pmkid_match.group(1) if pmkid_match else "unknown" - wps = wps_match.group(1) if wps_match else None # Fix: If WPS is not found, set None - - if pmkid == "no": - print(Fore.RED + Style.BRIGHT + f"[!] ALERT: Wi-Fi '{name}' has insecure settings!") - print(Fore.RED + " - PMKID attack is possible (disable-pmkid=no)") - risks_found = True - - # Fix: Do not report WPS if it's completely missing in the output - if wps is not None and wps != "disable": - print(Fore.RED + f"[!] ALERT: Wi-Fi '{name}' has WPS enabled ({wps}), Risk of PIN bruteforcing and Pixie Dust attacks.") - risks_found = True - - if not found_valid_output: - print(Fore.RED + "[-] ERROR: Unable to retrieve Wi-Fi interface settings. Unsupported RouterOS version or missing interface.") - - # Security profiles (ROS v6) - security_profiles_output = connection.send_command("/interface wireless security-profiles print detail") - if security_profiles_output.strip(): - profiles = security_profiles_output.split("\n\n") - for profile in profiles: - profile_name_match = re.search(r'name="([^"]+)"', profile) - pmkid_match = re.search(r'disable-pmkid=(\S+)', profile) - - profile_name = profile_name_match.group(1) if profile_name_match else "Unknown" - pmkid = pmkid_match.group(1) if pmkid_match else "unknown" - - if pmkid == "no": - print(Fore.RED + Style.BRIGHT + f"[!] ALERT: Security Profile '{profile_name}' allows PMKID attack! (disable-pmkid=no)") - risks_found = True - - # /interface wifi security print (ROS v7.10+ only) - if routeros_version >= Version("7.10"): - security_output = connection.send_command("/interface wifi security print") - if security_output.strip(): - securities = security_output.split("\n\n") - for security in securities: - sec_name_match = re.search(r'name="([^"]+)"', security) - pmkid_match = re.search(r'disable-pmkid=(\S+)', security) - wps_match = re.search(r'wps=(\S+)', security) - - if sec_name_match and (pmkid_match or wps_match): - sec_name = sec_name_match.group(1) - pmkid = pmkid_match.group(1) if pmkid_match else "unknown" - wps = wps_match.group(1) if wps_match else None # Fix: Avoid "WPS is enabled (unknown)" - - if pmkid == "no": - print(Fore.RED + Style.BRIGHT + f"[!] ALERT: Wi-Fi security profile '{sec_name}' has insecure settings!") - print(Fore.RED + " - PMKID attack is possible (disable-pmkid=no)") - risks_found = True - - if wps is not None and wps != "disable": - print(Fore.RED + f"[!] ALERT: Wi-Fi security profile '{sec_name}' has WPS enabled ({wps}), Risk of PIN bruteforcing and Pixie Dust attacks.") - risks_found = True - else: - print(Fore.RED + "[-] ERROR: Unable to retrieve Wi-Fi security settings.") - else: - print(Fore.CYAN + "[*] Skipping `/interface wifi security print` (not supported in this version)") - - except Exception as e: - print(Fore.RED + f"[-] ERROR: Failed to check Wi-Fi settings: {e}") - - if not risks_found: - print(Fore.GREEN + "[+] All Wi-Fi interfaces and security profiles have secure settings.") - print(Fore.YELLOW + "[*] If you use WPA-PSK or WPA2-PSK, take care of password strength. So that the handshake cannot be easily brute-forced.") - print(Fore.GREEN + "[+] No issues found.") - -# Check if UPnP is enabled -def check_upnp_status(connection): - # Separator outlet - separator("Checking UPnP Status") - command = "/ip upnp print" - output = connection.send_command(command) - - if "enabled: yes" in output: - print(Fore.RED + Style.BRIGHT + "[!] ALERT: UPnP is ENABLED! This is a very insecure protocol that automatically pushes internal hosts to the Internet. This protocol is used for automatic port forwarding and may also indicate a potential router compromise. Did you enable UPnP yourself?") - else: - print(Fore.GREEN + "[+] UPnP is disabled. No risk detected.") - print(Fore.GREEN + "[+] No issues found.") - -# Check if the router is acting as a DNS server -def check_dns_status(connection): - # Separator outlet - separator("Checking DNS Settings") - command = "/ip dns print" - output = connection.send_command(command) - - if "allow-remote-requests: yes" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: Router is acting as a DNS server! This is just a warning. The DNS port on your RouterOS should not be on the external interface.") - else: - print(Fore.GREEN + "[+] DNS remote requests are disabled. No risk detected.") - print(Fore.GREEN + "[+] No issues found.") - -# Check DDNS Settings -def check_ddns_status(connection): - # Separator outlet - separator("Checking DDNS Settings") - command = "/ip cloud print" - output = connection.send_command(command) - - if "ddns-enabled: yes" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: Dynamic DNS is enabled! Are you sure you need it?") - else: - print(Fore.GREEN + "[+] DDNS is disabled. No risk detected.") - print(Fore.GREEN + "[+] No issues found.") - -# Detect active PoE interfaces that might pose a risk to connected devices -def check_poe_status(connection): - # Separator outlet - separator("Checking PoE Status") - command = "/interface ethernet print detail" - output = connection.send_command(command) - - risks_found = False - interfaces = output.split("\n\n") - - for interface in interfaces: - name_match = re.search(r'name="([^"]+)"', interface) - poe_match = re.search(r'poe-out=(\S+)', interface) - name = name_match.group(1) if name_match else "Unknown" - poe = poe_match.group(1) if poe_match else "none" - - if poe in ["auto-on", "forced-on"]: - print(Fore.YELLOW + Style.BRIGHT + f"[!] CAUTION: PoE is enabled on {name}. Ensure that connected devices support PoE to prevent damage.") + # 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: - print(Fore.GREEN + "[+] No PoE-enabled interfaces detected.") - print(Fore.GREEN + "[+] No issues found.") + ok("No default usernames found") -# Checking RouterBOOT + +# 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): - # Separator outlet - separator("Checking RouterBOOT Protection") - command = "/system routerboard settings print" - output = connection.send_command(command) - + section("RouterBOOT Protection") + output = connection.send_command("/system routerboard settings print") if "protected-routerboot: disabled" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: RouterBOOT protection is disabled! This can allow unauthorized firmware changes and password resets via Netinstall.") + 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: - print(Fore.GREEN + "[+] RouterBOOT protection is enabled. No risk detected.") - print(Fore.GREEN + "[+] No issues found.") + ok("RouterBOOT protection is enabled") + +# SOCKS proxy check def check_socks_status(connection): - separator("Checking SOCKS Proxy Status") - command = "/ip socks print" - output = connection.send_command(command) - + section("SOCKS Proxy Status") + output = connection.send_command("/ip socks print") if "enabled: yes" in output: - print(Fore.RED + Style.BRIGHT + "[!] ALERT: SOCKS proxy is enabled! This may indicate a possible compromise of the device, the entry point to the internal network.") + 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: - print(Fore.GREEN + "[+] SOCKS proxy is disabled. No risk detected.") - print(Fore.GREEN + "[+] No issues found.") + ok("SOCKS proxy is disabled") -# Verify if RouterBOOT protection is enabled to prevent unauthorized firmware modifications + +# bandwidth-server check def check_bandwidth_server_status(connection): - # Separator outlet - separator("Checking Bandwidth Server Status") - command = "/tool bandwidth-server print" - output = connection.send_command(command) - + section("Bandwidth Server Status") + output = connection.send_command("/tool bandwidth-server print") if "enabled: yes" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: Bandwidth server is enabled! Possible unwanted traffic, possible CPU load.") + warn("Bandwidth server is enabled") + detail("May generate unwanted test traffic") + detail("Can increase CPU load under active use") else: - print(Fore.GREEN + "[+] Bandwidth server is disabled. No risk detected.") - print(Fore.GREEN + "[+] No issues found.") + ok("Bandwidth server is disabled") -# Analyze discovery protocols (CDP, LLDP, MNDP) that might expose network information + +# neighbor discovery config check def check_neighbor_discovery(connection): - # Separator outlet - separator("Checking Neighbor Discovery Protocols") - command = "/ip neighbor discovery-settings print" - output = connection.send_command(command) + section("Neighbor Discovery Protocols") + output = connection.send_command("/ip neighbor discovery-settings print") + risks_found = False if "discover-interface-list: all" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: RouterOS sends Discovery protocol packets to all interfaces. This can be used by an attacker to gather data about RouterOS.") + 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) - print(Fore.YELLOW + Style.BRIGHT + f"[!] Neighbor Discovery Protocols enabled: {protocols}") - if "discover-interface-list: all" not in output and not protocol_match: - print(Fore.GREEN + "[+] No security risks found in Neighbor Discovery Protocol settings.") - print(Fore.GREEN + "[+] No issues found.") - -# Ensure a minimum password length policy is enforced -def check_password_length_policy(connection): - # Separator outlet - separator("Checking Password Policy") - command = "/user settings print" - output = connection.send_command(command) - - if "minimum-password-length: 0" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: No minimum password length is enforced! The length of the created passwords must be taken into account.") - if "minimum-password-length: 0" not in output: - print(Fore.GREEN + "[+] Password policy is enforced. No risk detected.") - print(Fore.GREEN + "[+] No issues found.") - -# Analyze SSH security settings, including strong encryption and port forwarding risks -def check_ssh_security(connection): - # Separator outlet - separator("Checking SSH Security") - command = "/ip ssh print" - output = connection.send_command(command) - - if "forwarding-enabled: both" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: SSH Dynamic Port Forwarding is enabled! This could indicate a RouterOS compromise, and SSH DPF could also be used by an attacker as a pivoting technique.") - if "strong-crypto: no" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: strong-crypto is disabled! It is recommended to enable it to enhance security. This will:") - print(Fore.YELLOW + " - Use stronger encryption, HMAC algorithms, and larger DH primes;") - print(Fore.YELLOW + " - Prefer 256-bit encryption, disable null encryption, prefer SHA-256;") - print(Fore.YELLOW + " - Disable MD5, use 2048-bit prime for Diffie-Hellman exchange;") - if "forwarding-enabled: both" not in output and "strong-crypto: no" not in output: - print(Fore.GREEN + "[+] SSH security settings are properly configured.") - print(Fore.GREEN + "[+] No issues found.") - -# Check if connection tracking is enabled, which may impact performance -def check_connection_tracking(connection): - # Separator outlet - separator("Checking Connection Tracking") - command = "/ip firewall connection tracking print" - output = connection.send_command(command) - if "enabled: auto" in output or "enabled: on" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: Connection Tracking is enabled! This means RouterOS is tracks connection statuses.") - print(Fore.YELLOW + " - If this device is a transit router and does NOT use NAT, consider disabling connection tracking to reduce CPU load.") - - if "enabled: auto" not in output and "enabled: on" not in output: - print(Fore.GREEN + "[+] Connection Tracking is properly configured.") - print(Fore.GREEN + "[+] No issues found.") - -# Verify if RoMON is enabled, which might expose Layer 2 management access -def check_romon_status(connection): - # Separator outlet - separator("Checking RoMON Status") - command = "/tool romon print" - output = connection.send_command(command) - - if "enabled: yes" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: RoMON is enabled! This allows Layer 2 management access, which may expose the router to unauthorized control.") - print(Fore.YELLOW + " - If RoMON is not required, disable it to reduce attack surface.") - if "enabled: yes" not in output: - print(Fore.GREEN + "[+] RoMON is disabled. No risk detected.") - print(Fore.GREEN + "[+] No issues found.") - -# Analyze MAC-based Winbox access settings -def check_mac_winbox_security(connection): - # Separator outlet - separator("Checking Winbox MAC Server Settings") - - # MAC-Winbox Server - command = "tool mac-server mac-winbox print" - output = connection.send_command(command) - if "allowed-interface-list: all" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: MAC Winbox access is enabled on all interfaces. This compromises the security of the Winbox interface.") - else: - print(Fore.GREEN + "[+] MAC Winbox are properly restricted.") - - # MAC-Server - command = "tool mac-server print" - output = connection.send_command(command) - if "allowed-interface-list: all" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: MAC Telnet access is enabled on all interfaces. This compromises the security of the Winbox interface.") - else: - print(Fore.GREEN + "[+] MAC Telnet are properly restricted.") - - # MAC Ping - command = "tool mac-server ping print" - output = connection.send_command(command) - if "enabled: yes" in output: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: MAC Ping is enabled. Possible unwanted traffic.") - else: - print(Fore.GREEN + "[+] MAC Ping are properly restricted.") - -# Check for weak SNMP community strings that could be exploited -def check_snmp(connection): - # Separator outlet - separator("Checking SNMP Community Strings") - command = "/snmp community print" - output = connection.send_command(command) - - bad_names = ["public", "private", "admin", "mikrotik", "mikrotik_admin", "root", "routeros", "zabbix"] - risks_found = False - - for line in output.splitlines(): - match = re.search(r'^\s*\d+\s+[*X]?\s*([\w-]+)', line) - if match: - community_name = match.group(1).lower() - if community_name in bad_names: - print(Fore.YELLOW + Style.BRIGHT + f"[!] CAUTION: Weak SNMP community string detected: '{community_name}'. Change it to a secure, unique value.") - risks_found = True + warn(f"Neighbor discovery protocols enabled: {protocols}") + detail("Limit discovery to management or trusted interfaces only") + risks_found = True if not risks_found: - print(Fore.GREEN + "[+] SNMP community strings checked. No weak values detected.") - print(Fore.GREEN + "[+] No issues found.") + ok("No security risks found in Neighbor Discovery configuration") -# Detect and analyze firewall NAT rules that could expose internal services + +# 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): - # Separator outlet - separator("Checking Firewall NAT Rules") - command = "/ip firewall nat print" - output = connection.send_command(command) + 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: - print(Fore.YELLOW + Style.BRIGHT + "[!] CAUTION: Destination NAT (dst-nat/netmap) rules detected! Exposing devices to the internet can be dangerous.") - print(Fore.YELLOW + Style.BRIGHT + "[*] Similar rules can also be created by the attacker. Did you really create these rules yourself?") - print(Fore.YELLOW + " - Review the following 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: - print(Fore.YELLOW + f" {rule}") - if not dst_nat_rules: - print(Fore.GREEN + "[+] No Destination NAT (dst-nat/netmap) rules detected. No risks found.") - print(Fore.GREEN + "[+] No issues found.") + detail(rule) + else: + ok("No Destination NAT (dst-nat/netmap) rules detected") -# Identify potentially malicious scheduled tasks + +# scheduler / persistence check def detect_malicious_schedulers(connection): - # Separator outlet - separator("Checking for Malicious Schedulers") - command = "/system scheduler print detail" - output = connection.send_command(command) - + 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 - for task in output.split("\n\n"): name_match = re.search(r'name="?([^"]+)"?', task) event_match = re.search(r'on-event="?([^"\n]+)"?', task) policy_match = re.search(r'policy=([\w,]+)', task) @@ -611,196 +664,223 @@ def detect_malicious_schedulers(connection): name = name_match.group(1) if name_match else "Unknown" event = event_match.group(1).strip() if event_match else "" - policy = policy_match.group(1).split(",") if policy_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) - # DEBUG - print(Fore.CYAN + f"[*] Checking: '{name}' → {event}") + issues = [] - # Fetch detection - 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) - print(Fore.YELLOW + f"[!] Noted fetched file: {fetched_file}") - - # Import detection (checks if imported file was fetched earlier) + # 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: - print(Fore.RED + Style.BRIGHT + f"[!] ALERT: '{name}' is a BACKDOOR!") - print(Fore.RED + " - This scheduler imports a previously fetched script.") - print(Fore.RED + " - Attacker can inject any command remotely via this script.") - print(Fore.RED + f" - Interval: {interval_value}{interval_unit}, ensuring persistence.") - risks_found = True + 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") - # High privileges checking + # dangerous policies dangerous_policies = {"password", "sensitive", "sniff", "ftp"} - if any(p in dangerous_policies for p in policy): - print(Fore.RED + Style.BRIGHT + f"[!] ALERT: '{name}' has HIGH PRIVILEGES!") - print(Fore.RED + f" - It has dangerous permissions: {', '.join(policy)}") - risks_found = True + 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)}") - # Reboot detection (Anti-forensics & persistence check) + # reboots if "reboot" in event: if interval_value and interval_unit in ["s", "m", "h"] and interval_value < 12: - print(Fore.RED + f"[!] ALERT: '{name}' reboots router TOO FREQUENTLY ({interval_value}{interval_unit})!") - print(Fore.RED + " - This may be an attempt to prevent log analysis (anti-forensics).") - risks_found = True + issues.append(f"Frequently reboots router ({interval_value}{interval_unit}) - possible anti-forensics") else: - print(Fore.YELLOW + f"[!] CAUTION: '{name}' schedules a reboot.") - print(Fore.YELLOW + " - Ensure this is intentional and not used to hide attacks.") - continue + issues.append("Schedules router reboot - verify it is intentional") - # Frequent execution detection + # tight intervals if interval_value and interval_unit in ["s", "m", "h"] and interval_value < 25: - print(Fore.RED + Style.BRIGHT + f"[!] ALERT: '{name}' executes TOO FREQUENTLY ({interval_value}{interval_unit})!") - print(Fore.RED + " - This indicates botnet-like persistence.") + 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: - print(Fore.GREEN + "[+] No malicious schedulers detected.") + ok("No suspicious schedulers detected") -# Checking DNS Static Entries + +# static DNS entries check def check_static_dns_entries(connection): - # Separator outlet - separator("Checking Static DNS Entries") - command = "/ip dns static print detail" - output = connection.send_command(command) - + 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: - print(Fore.YELLOW + "[!] WARNING: The following static DNS entries exist:") + warn("Static DNS entries are configured") + detail("Verify that each record is legitimate and expected") for name, address in dns_entries: - print(Fore.CYAN + f" - {name} → {address}") - - print(Fore.YELLOW + "[*] Were you the one who created those static DNS records? Make sure.") - print(Fore.YELLOW + "[*] Attackers during RouterOS post-exploitation like to tamper with DNS record settings, for example, for phishing purposes.") + detail(f"{name} → {address}") + detail("Attackers often modify DNS for phishing or traffic redirection during post-exploitation") else: - print(Fore.GREEN + "[+] No static DNS entries found.") + ok("No static DNS entries found") -# Retrieve router uptime -def get_router_uptime(connection): - # Separator outlet - separator("Checking Router Uptime") - command = "/system resource print" - output = connection.send_command(command) - # Extract uptime value - match = re.search(r"uptime:\s*([\w\d\s]+)", output) - - if match: - uptime_raw = match.group(1) - weeks = days = hours = minutes = 0 +# 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"} - # Extract individual time units - if "w" in uptime_raw: - weeks = int(re.search(r"(\d+)w", uptime_raw).group(1)) - if "d" in uptime_raw: - days = int(re.search(r"(\d+)d", uptime_raw).group(1)) - if "h" in uptime_raw: - hours = int(re.search(r"(\d+)h", uptime_raw).group(1)) - if "m" in uptime_raw: - minutes = int(re.search(r"(\d+)m", uptime_raw).group(1)) - - # Convert weeks to days and format output - total_days = weeks * 7 + days - print(Fore.GREEN + Style.BRIGHT + f"[*] Router Uptime: {total_days} days, {hours} hours, {minutes} minutes") - else: - print(Fore.RED + "[-] ERROR: Could not retrieve uptime.") - -# Require user confirmation before proceeding, emphasizing legal responsibility -def confirm_legal_usage(): - print(" " + "WARNING: This tool is for security auditing of YOUR OWN RouterOS devices.") - print(" " + "Unauthorized use may be illegal. Proceed responsibly.\n") - response = input(" " + "Do you wish to proceed? [yes/no]: ").strip() - - if response.lower() != "yes": - print("\nOperation aborted. Exiting...") - sys.exit(0) - -# # Parse command-line arguments, establish connection, and execute all security checks -def main(): - # Print banner - banner() - - # Argument parsing - parser = argparse.ArgumentParser() - parser.add_argument("--ip", help="The address of your MikroTik router") - parser.add_argument("--username", help="SSH username (RO account can be used)") - parser.add_argument("--password", help="SSH password") - parser.add_argument("--port", type=int, default=22, help="SSH port (default: 22)") - args = parser.parse_args() - - if len(sys.argv) == 2 and sys.argv[1] in ["-h", "--help"]: - parser.print_help() - sys.exit(0) - - if not args.ip or not args.username or not args.password: - print(Fore.YELLOW + Style.BRIGHT + "[!] ERROR: Missing required arguments") - print(Fore.YELLOW + "[!] Use 'sara --help' for more information") + if not selected: + error("No profiles specified") + info("Use at least one profile: system, protocols, wifi") sys.exit(1) - confirm_legal_usage() + invalid = selected - valid + if invalid: + error("Unknown profiles: " + ", ".join(sorted(invalid))) + info("Valid profiles: system, protocols, wifi") + sys.exit(1) - # Start timer - start_time = time.time() + return selected - # Connecting to the router - connection = connect_to_router(args.ip, args.username, args.password, args.port) - # Execute all implemented security checks in sequence - check_routeros_version(connection) - check_smb(connection) - check_rmi_services(connection) - check_default_users(connection) - checking_access_to_RMI(connection) - check_wifi_security(connection) - check_upnp_status(connection) - check_dns_status(connection) - check_ddns_status(connection) - check_poe_status(connection) - check_routerboot_protection(connection) - check_socks_status(connection) - check_bandwidth_server_status(connection) - check_neighbor_discovery(connection) - check_password_length_policy(connection) - check_ssh_security(connection) - check_connection_tracking(connection) - check_romon_status(connection) - check_mac_winbox_security(connection) - check_snmp(connection) - check_dst_nat_rules(connection) - detect_malicious_schedulers(connection) - check_static_dns_entries(connection) - get_router_uptime(connection) +# 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})") - # Print a blank line for better output formatting - print () + 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) - # Close the SSH connection to the router connection.disconnect() - print(Fore.GREEN + Style.BRIGHT + f"[*] Disconnected from RouterOS ({args.ip}:{args.port})") + print(f"[*] Disconnected from RouterOS ({args.ip})") - # Measure and display the total execution time - end_time = time.time() - total_time = round(end_time - start_time, 2) - # Print a closing message emphasizing continuous security improvements - print(Fore.GREEN + Style.BRIGHT + f"[*] All checks have been completed. Security inspection completed in {total_time} seconds\n") - print(Fore.MAGENTA + Style.BRIGHT + "[*] " + Fore.WHITE + "Remember: " + Fore.RED + "Security" + Fore.WHITE + " is a " + Fore.GREEN + "process" + Fore.WHITE + ", not a " + Fore.YELLOW + "state.") +# 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() \ No newline at end of file diff --git a/setup.py b/setup.py index 6b377c9..d84454d 100644 --- a/setup.py +++ b/setup.py @@ -2,23 +2,24 @@ from setuptools import setup, find_packages setup( name="sara", - version="1.1.1", - url="https://github.com/casterbyte/Sara", - author="Magama Bazarov", - author_email="magamabazarov@mailbox.org", + version="1.3", + url="https://github.com/caster0x00/Sara", + author="Mahama Bazarov", + author_email="mahamabazarov@mailbox.org", scripts=['sara.py'], description="RouterOS Security Inspector", long_description=open('README.md', encoding="utf8").read(), long_description_content_type='text/markdown', license="Apache-2.0", - keywords=['mikrotik', 'routeros', 'config analyzer', 'network security',], + keywords=['mikrotik', 'routeros', 'config analyzer', 'network security', 'cve',], packages=find_packages(), install_requires=[ 'colorama', 'netmiko', 'packaging', + 'requests', ], - py_modules=['cve_lookup'], + py_modules=['cve_analyzer'], entry_points={ "console_scripts": ["sara = sara:main"], },