Skip to content
Back to the Lab

MikroTrick Hijacks MikroTik Routers Over SSH

MikroTrick chains two RouterOS SSH flaws for unauthenticated administrator access. Reproduce the broken RSA check safely, then patch and investigate.

Generated Egnworks banner describing the MikroTrick RouterOS SSH vulnerability chain

MikroTrick is the name CERT Polska gave to an actively exploited chain that crosses two MikroTik RouterOS SSH failures. The first failure accepts an RSA identity without comparing the entire public key. The second turns a specially formed login identity into a fully privileged RouterOS session. An internet-reachable SSH service is therefore not merely a management exposure on affected releases. It can become an unauthenticated route to complete control of the router.

MikroTik shipped fixes in RouterOS 7.25 beta 3, 7.24.2, 7.23.4, and 6.49.21 on September 3, 2026. CERT Polska reported confirmed attacks against public RouterOS SSH services from at least September 2. The window between observed exploitation and public technical disclosure makes this an incident-response problem, not only a patch-management problem.

This article separates the six disclosed bugs, reconstructs the cryptographic design error in a harmless local model, proves the corrected behavior with regression tests, and provides a collector for exported RouterOS evidence. It does not reproduce the RouterOS wire protocol, automate exploitation, or scan a network. The local proof demonstrates the invariant that failed without providing an operational router takeover tool.

Six disclosures, one named chain#

CERT Polska disclosed six RouterOS vulnerabilities together, but their prerequisites and effects are different. Only CVE-2026-67276 and CVE-2026-86060 form the chain named MikroTrick. CVE-2026-67277 was also observed in the active-risk period and was added to the CISA Known Exploited Vulnerabilities catalog alongside CVE-2026-86060, but it is a bandwidth-test flaw rather than the SSH authentication half of MikroTrick.

CVEAffected surfaceFailureSecurity resultFixed releases
CVE-2026-67276SSH public-key authenticationRouterOS matched RSA type and modulus but omitted the exponentAuthentication as a known RSA-backed user without that user’s private key7.23.4, 7.24.2, 7.25 beta 3
CVE-2026-86060SSH login policyA prohibited username prefix altered how the login helper received policy dataPrivilege escalation to a full administrative session6.49.21, 7.23.4, 7.24.2, 7.25 beta 3
CVE-2026-67277Bandwidth-testA related connection reached a post-authentication state early; uninitialized data and an integer underflow followedKernel memory disclosure or remote restart6.49.21, 7.23.4, 7.24.2, 7.25 beta 3
CVE-2026-67278X.509 validationMalformed RSA PKCS#1 v1.5 signatures could validate under an exponent-three trust anchorImpersonation of TLS servers contacted by RouterOS7.23.4, 7.24.2, 7.25 beta 3
CVE-2026-67279SSH state machineRekeying before authentication advanced the connection into a command-capable stateUnauthenticated manipulation of RouterOS-managed files6.49.21, 7.23.4, 7.24.2, 7.25 beta 3
CVE-2026-67281WebFig /jsproxyA stale principal pointer and parent traversal corrupted the authorization boundaryUnauthenticated disclosure of root-owned files and configuration data7.23.4, 7.24.2, 7.25 beta 3

CVE-2026-67276 begins at RouterOS 7.9 and affects releases before 7.23.4, plus the 7.24 branch before 7.24.2. The other version ranges are broader or narrower as shown by CERT Polska’s advisory. Version matching must therefore be performed per CVE, not by assuming every RouterOS 6 and 7 build is vulnerable to every issue.

Where MikroTrick crosses the trust boundary#

An RSA public key is not only its modulus n. It is the pair (n, e), where e is the public exponent. An SSH server that assigns a key to an account must bind the complete presented key to that authorized identity, then verify the signature under the same authorized key. Comparing n while accepting a client-provided e creates a split identity: authorization answers one question, while cryptographic verification uses a different object.

CERT Polska found exactly that separation in affected RouterOS releases. The authorized-key lookup checked the key type and modulus but did not compare the exponent. Signature verification then used the key supplied by the client. A client could reuse a known authorized modulus, select an exponent that makes verification trivial, and satisfy the signature check without possessing the authorized private key.

Knowing a public modulus is not equivalent to stealing secret material. Public keys are designed to be public. The failure is that the server treated one component as the account identity while letting the claimant replace another component that determines verification behavior.

flowchart TD
    A["SSH key offer"] --> B{"Compare full key?"}
    B -->|"Affected build: type + n"| C["Client-selected e survives"]
    C --> D["Forged proof opens target session"]
    D --> E["Crafted login identity changes policy"]
    E --> F["Full RouterOS administration"]
    B -->|"Fixed build: type + n + e"| G["Reject mismatched identity"]

CVE-2026-86060 supplies the privilege transition. RouterOS did not safely handle an SSH username beginning with a disallowed character. The crafted identity changed the effective policy information passed through the login path, and the resulting session received full administrative rights. The first bug reaches an SSH command channel under a targeted identity. The second removes the need for that identity to have useful privileges.

This composition is why exposure matters. The chain requires the SSH server to be reachable, a usable account name, and the modulus of an authorized RSA key. It does not require the corresponding private key. RouterOS defaults normally block management access from the internet, but manually opened firewall rules and broad /ip service address ranges defeat that protection.

Safe proof of the RSA identity failure#

The following program is a local mathematical model, not an SSH client. It uses tiny textbook RSA values, omits encoding and hashing, sends no packets, and contains no RouterOS protocol logic. Its purpose is to make the broken invariant observable: a vulnerable identity lookup accepts a different public key because only the modulus matches.

Save it as rsa_identity_regression.py and run it with Python 3.11 or later.

from dataclasses import dataclass


@dataclass(frozen=True)
class PublicKey:
    modulus: int
    exponent: int


def vulnerable_identity_match(
    authorized: PublicKey,
    presented: PublicKey,
) -> bool:
    return authorized.modulus == presented.modulus


def fixed_identity_match(
    authorized: PublicKey,
    presented: PublicKey,
) -> bool:
    return authorized == presented


def verify_textbook_rsa(
    message: int,
    signature: int,
    verification_key: PublicKey,
) -> bool:
    recovered = pow(
        signature,
        verification_key.exponent,
        verification_key.modulus,
    )
    return recovered == message % verification_key.modulus


def vulnerable_authenticate(
    message: int,
    signature: int,
    authorized: PublicKey,
    presented: PublicKey,
) -> bool:
    return vulnerable_identity_match(
        authorized,
        presented,
    ) and verify_textbook_rsa(message, signature, presented)


def fixed_authenticate(
    message: int,
    signature: int,
    authorized: PublicKey,
    presented: PublicKey,
) -> bool:
    return fixed_identity_match(
        authorized,
        presented,
    ) and verify_textbook_rsa(message, signature, authorized)


def main() -> None:
    authorized = PublicKey(modulus=3233, exponent=17)
    authorized_private_exponent = 2753
    message = 42

    substituted = PublicKey(modulus=3233, exponent=1)
    forged_signature = message % substituted.modulus

    legitimate_signature = pow(
        message,
        authorized_private_exponent,
        authorized.modulus,
    )

    vulnerable_forgery = vulnerable_authenticate(
        message,
        forged_signature,
        authorized,
        substituted,
    )
    fixed_forgery = fixed_authenticate(
        message,
        forged_signature,
        authorized,
        substituted,
    )
    fixed_legitimate = fixed_authenticate(
        message,
        legitimate_signature,
        authorized,
        authorized,
    )

    assert vulnerable_forgery is True
    assert fixed_forgery is False
    assert fixed_legitimate is True

    print(f"vulnerable forged identity: {vulnerable_forgery}")
    print(f"fixed forged identity:      {fixed_forgery}")
    print(f"fixed legitimate identity:  {fixed_legitimate}")


if __name__ == "__main__":
    main()

The vulnerable path returns True because the modulus matches and verification uses the substituted exponent. With e = 1, textbook verification reduces to the supplied signature value itself. The fixed path rejects the presented key before verification because (3233, 1) is not (3233, 17). A legitimate signature produced with the authorized private exponent still passes.

vulnerable forged identity: True
fixed forged identity:      False
fixed legitimate identity:  True

The repair has two inseparable properties. It compares the entire public-key identity, and it verifies the proof under the stored authorized key rather than a partially matched client object. Merely blocking exponent one is incomplete. A protocol implementation should reject identity substitution as a class, not maintain a denylist of suspicious exponent values.

Turn the fix into a regression contract#

A unit test should express security behavior in terms of invariants. The negative case must present the same modulus with a different exponent and prove rejection. The positive control must prove that the authorized pair still works. This prevents a future refactor from restoring modulus-only comparison while keeping ordinary authentication tests green.

Save the next block as test_rsa_identity.py beside the first file. It imports the same functions and can be executed without an external test framework.

from rsa_identity_regression import (
    PublicKey,
    fixed_authenticate,
    fixed_identity_match,
    verify_textbook_rsa,
)


def test_same_modulus_different_exponent_is_rejected() -> None:
    stored = PublicKey(modulus=3233, exponent=17)
    substituted = PublicKey(modulus=3233, exponent=1)
    assert not fixed_identity_match(stored, substituted)
    assert not fixed_authenticate(42, 42, stored, substituted)


def test_authorized_key_is_accepted() -> None:
    stored = PublicKey(modulus=3233, exponent=17)
    signature = pow(42, 2753, stored.modulus)
    assert fixed_identity_match(stored, stored)
    assert verify_textbook_rsa(42, signature, stored)
    assert fixed_authenticate(42, signature, stored, stored)


def main() -> None:
    test_same_modulus_different_exponent_is_rejected()
    test_authorized_key_is_accepted()
    print("2 regression tests passed")


if __name__ == "__main__":
    main()

The model is deliberately smaller than real SSH cryptography. Production RSA authentication verifies an encoded signature over protocol-specific data and applies algorithm constraints. Those details do not change the authorization invariant demonstrated here: every security-relevant component of the presented key must equal the key bound to the account, and the trusted stored object must drive verification.

Patch status is a version assertion#

The operational fix is to install a release that contains MikroTik’s patch. Configuration hardening reduces exposure but does not correct any of the six implementation defects. The supported fixed floors disclosed by MikroTik are exact and should be evaluated by release channel.

Installed branchVulnerable conditionMinimum disclosed fixed release
RouterOS 6 stable/long-termBelow 6.49.21 for the applicable RouterOS 6 CVEs6.49.21
RouterOS 7 up to 7.23Below 7.23.4 for applicable CVEs7.23.4
RouterOS 7.24Below 7.24.27.24.2
RouterOS testingEarlier than the fixed beta7.25 beta 3

Operators should use the latest appropriate release in their chosen channel, not intentionally stop at the minimum forever. The table records the first disclosed fixes so inventory systems can make a deterministic vulnerable-versus-fixed decision.

The following RouterOS commands collect the version, management exposure, device-mode state, local identities, persistence-capable objects, proxies, and tunnels. Run them from an already trusted administrative path. Redirect terminal output into a case file on the analyst workstation rather than creating new files on a potentially compromised router.

/system resource print
/system package print
/system device-mode print
/ip service print detail
/user print detail
/user ssh-keys print detail
/system script print detail
/system scheduler print detail
/ip socks print
/ip proxy print
/interface print detail
/ip firewall filter print detail
/log print without-paging

If SSH, WWW, WWW-SSL, or bandwidth-test cannot be patched immediately, CERT Polska recommends disabling exposure or restricting it to trusted management networks. MikroTik recommends placing management behind a strong VPN such as WireGuard rather than publishing management ports. This is a temporary containment measure. It does not make a vulnerable build safe.

Collect and score exported evidence#

The collector below reads text exported from RouterOS commands. It never connects to a device. It recognizes the public indicators reported by CERT Polska, highlights a flagged device, records unknown local users supplied by the investigator, and detects broad SSH service exposure in conventional /ip service print detail output.

Save it as mikrotrick_triage.py.

import argparse
import ipaddress
import json
import re
from dataclasses import asdict, dataclass
from pathlib import Path


KNOWN_ATTACK_IPS = {"82.192.72.4", "103.102.31.18"}
LOGIN_PATTERN = re.compile(
    r"login failure for user -2 from (?P<ip>[0-9a-fA-F:.]+) via ssh"
)
USER_ADDED_PATTERN = re.compile(
    r"user (?P<user>\S+) added by ssh:-2@(?P<ip>[0-9a-fA-F:.]+)"
)
USER_RECORD_PATTERN = re.compile(
    r"^\s*\d+\s+name=(?P<user>[^\s]+)\s+group=",
    re.MULTILINE,
)
SSH_RECORD_PATTERN = re.compile(r"\bname=ssh\b(?P<record>.*)")
ADDRESS_PATTERN = re.compile(r"\baddress=(?P<value>[^\s]+)")


@dataclass(frozen=True)
class Finding:
    severity: str
    kind: str
    evidence: str


def parse_allowed_users(raw: str) -> set[str]:
    return {item.strip() for item in raw.split(",") if item.strip()}


def address_scope_is_broad(value: str) -> bool:
    if value in {"", "0.0.0.0/0", "::/0"}:
        return True
    networks = []
    for item in value.split(","):
        try:
            networks.append(ipaddress.ip_network(item.strip(), strict=False))
        except ValueError:
            return True
    return any(network.prefixlen == 0 for network in networks)


def inspect(text: str, allowed_users: set[str]) -> list[Finding]:
    findings: list[Finding] = []

    if re.search(r"\bflagged:\s*yes\b", text, re.IGNORECASE):
        findings.append(Finding("critical", "flagged", "flagged: yes"))

    for match in LOGIN_PATTERN.finditer(text):
        ip = match.group("ip")
        severity = "critical" if ip in KNOWN_ATTACK_IPS else "high"
        findings.append(Finding(severity, "crafted-login", match.group(0)))

    for match in USER_ADDED_PATTERN.finditer(text):
        findings.append(Finding("critical", "ssh-user-created", match.group(0)))

    for ip in sorted(KNOWN_ATTACK_IPS):
        if ip in text:
            findings.append(Finding("critical", "reported-ip", ip))

    discovered_users = {
        match.group("user").strip('"')
        for match in USER_RECORD_PATTERN.finditer(text)
    }
    for user in sorted(discovered_users - allowed_users):
        severity = "critical" if user == "ops" else "high"
        findings.append(Finding(severity, "unknown-user", user))

    for line in text.splitlines():
        ssh = SSH_RECORD_PATTERN.search(line)
        if not ssh or "disabled=yes" in line:
            continue
        address = ADDRESS_PATTERN.search(ssh.group("record"))
        value = address.group("value") if address else ""
        if address_scope_is_broad(value):
            findings.append(Finding("high", "broad-ssh-service", line.strip()))

    unique = {
        (finding.severity, finding.kind, finding.evidence): finding
        for finding in findings
    }
    return list(unique.values())


def verdict(findings: list[Finding]) -> str:
    levels = {finding.severity for finding in findings}
    if "critical" in levels:
        return "possible-compromise"
    if "high" in levels:
        return "exposed-or-suspicious"
    return "no-public-indicator-found"


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("export", type=Path)
    parser.add_argument("--allowed-users", required=True)
    args = parser.parse_args()

    text = args.export.read_text(encoding="utf-8", errors="replace")
    findings = inspect(text, parse_allowed_users(args.allowed_users))
    output = {
        "verdict": verdict(findings),
        "finding_count": len(findings),
        "findings": [asdict(finding) for finding in findings],
    }
    print(json.dumps(output, indent=2, sort_keys=True))


if __name__ == "__main__":
    main()

Use the exact expected local usernames as the allowlist. The following fixture represents a compromised-looking export and remains entirely offline.

       version: 7.24.1
       flagged: yes
 0 name=admin group=full
 1 name=ops group=full
 0 name=ssh port=22 address=0.0.0.0/0 disabled=no
login failure for user -2 from 82.192.72.4 via ssh
user ops added by ssh:-2@82.192.72.4
python3 mikrotrick_triage.py router-export.txt --allowed-users admin

A possible-compromise result is a triage decision, not proof that one exact CVE was used. The Flagged mechanism recognizes selected suspicious configuration patterns at startup, and other attack paths can create similar artifacts. The inverse is equally important: no-public-indicator-found means only that the supplied export lacks these public indicators. CERT Polska and MikroTik both warn that flagged: no does not prove the router is clean.

Patch, contain, then rebuild when evidence demands it#

Patching stops the known vulnerable behavior but does not delete an account, script, scheduler entry, proxy, tunnel, or credential already placed by an attacker. A router with a critical Flagged log entry, a ssh:-2 user-creation record, an unexplained ops account, or other unauthorized state must be handled as compromised.

SituationRequired actionWhat not to conclude
Vulnerable version, no suspicious evidenceRestrict management exposure, update immediately, then inspect stateLack of an IOC does not prove no prior access
Public SSH plus reported exploit artifactsIsolate the device and preserve logs and configuration before resetInstalling the patch does not remove persistence
flagged: yes after upgradePreserve evidence and follow the vendor’s Flagged procedureThe marker does not identify the exact vulnerability used
Confirmed or credible compromiseFactory reset, rebuild from a trusted baseline, and rotate passwords, SSH keys, VPN secrets, API credentials, and adjacent secretsDo not restore a full backup captured after compromise

Evidence should be collected before factory reset because volatile and local records may be destroyed. Store the RouterOS version, package inventory, device-mode output, logs, exported configuration, firewall rules, users, SSH keys, scripts, schedulers, proxies, tunnels, and service exposure with acquisition timestamps. Report the incident to the appropriate CSIRT when applicable.

After evidence preservation, rebuild rather than cosmetically deleting the visible ops account. CERT Polska explicitly advises factory reset and reconfiguration from a trusted, verified configuration. A full backup taken from the suspect router can reintroduce attacker-controlled state. Rotate every secret that the router stored, used, proxied, or could reach because administrative router control places downstream credentials and traffic at risk.

The corrected state has three layers. The binary is on a fixed release. Management services are unreachable from untrusted networks and preferably accessed through WireGuard. The configuration is either verified against a known-good baseline or rebuilt after compromise. Missing any one of those layers leaves either the original implementation risk, avoidable exposure, or attacker persistence unresolved.

Detection needs negative controls#

Public IOCs are high-value but short-lived. The two IP addresses and ops username describe observed activity, not the complete attacker population. Detection should retain exact matches while also modeling the underlying behavior: crafted SSH login failures, SSH-originated user creation, new full-group users, management services widened to public address ranges, unexplained scripts or scheduler jobs, newly enabled SOCKS or web proxies, and new tunnels.

Baselining matters because RouterOS is legitimately automated in many networks. A scheduled configuration export or an approved tunnel is not malicious merely because it is persistence-capable. Compare semantic state against a signed or otherwise integrity-protected known-good export, then review additions, permission changes, and exposure changes. Raw line diffs alone can be noisy when RouterOS reorders output.

Validation should include negative controls. Feed the collector a clean export containing only approved users and SSH restricted to a management prefix. It should produce no-public-indicator-found. Feed it the fixture above and confirm possible-compromise. Then remove flagged: yes while leaving ssh:-2 evidence in place. The result must remain a possible compromise, proving the detector does not depend on one vendor marker.

MikroTrick is a compact example of why authentication reviews cannot stop at whether a signature primitive returns success. The security property spans parsing, identity matching, choice of verification object, protocol state, and the policy data passed into session creation. The local PoC makes one broken boundary measurable. The fixed releases repair the implementation. The investigation workflow answers the separate question of whether the attacker arrived first.

References#

MikroTik September 2026 vulnerability

CERT Polska active MikroTrick exploitation

CERT Polska RouterOS CVE technical advisory

RouterOS device mode and Flagged status

CISA Known Exploited Vulnerabilities Catalog

Canadian Centre MikroTik RouterOS alert