| name | routeros-mac-telnet |
| description | MAC-Telnet protocol (MikroTik Layer-2 terminal/exec over UDP 20561) wire format, session handshake, and MD5 + MTWEI (EC-SRP) authentication. Use when: implementing or debugging a MAC-Telnet client/server, reaching a RouterOS device by MAC address without IP, parsing MAC-Telnet packets, understanding the WinBox-style L2 terminal, deciding between classic MD5 and modern MTWEI auth, or when the user mentions MAC-Telnet, mactelnet, mactelnetd, port 20561, or MTWEI/EC-SRP RouterOS login. |
MAC-Telnet — MikroTik Layer-2 Terminal Protocol
MAC-Telnet lets you open an interactive RouterOS terminal (or run commands)
addressing a device by its MAC address — you do not need to know or route to
the target's IP. It works across a Layer-2 broadcast domain even when the target
has no IP, a wrong IP, or an IP you cannot route to, which is why WISPs and
provisioning tools rely on it. WinBox's "MAC Telnet" and the tools/mac-telnet
CLI use this protocol family.
Why This Matters for Agents
- It is the only way to get a shell on a freshly-unboxed or misconfigured
RouterOS device that has no reachable IP.
- The protocol carries its own 6+6-byte source/destination MAC addressing
inside every packet, independent of the outer UDP/L2 delivery. This trips up
implementers who assume normal UDP semantics.
- Authentication has two incompatible modes — classic MD5 and modern
MTWEI (EC-SRP over Curve25519). Current RouterOS 7.x defaults to MTWEI;
getting the mode-detection wrong is the single most common failure.
- It is simple enough to implement from scratch for the MD5 path; MTWEI needs an
elliptic-curve library.
Protocol Basics
| Property | Value |
|---|
| Transport | UDP |
| Port | 20561 (server listens; client may use an ephemeral or matching source port) |
| Addressing | In-packet 6-byte src + 6-byte dst MAC (the real addressing); outer UDP/IP is just transport |
| Delivery | Layer-2 — broadcast or unicast Ethernet frame to the target MAC; does not cross routers |
| Reliability | Application-level: byte-counter ACKs + timed retransmission (UDP gives none) |
| Session | Stateful handshake → authenticated → raw terminal stream → teardown |
| Auth | MD5 (legacy) or MTWEI / EC-SRP (current RouterOS 7.x default) |
| Max packet | 1500 bytes (MT_PACKET_LEN) |
MAC-Telnet (UDP 20561) is a sibling of MNDP (UDP 5678) from the same
MikroTik L2 toolset: MNDP discovers the device and its MAC; MAC-Telnet then
connects to that MAC. See the routeros-mndp skill for discovery. They are
otherwise independent wire formats — MNDP is a one-shot TLV announcement with no
session, auth, control-block magic, or 00 15 client-type.
Packet Header (22 bytes)
Every SESSIONSTART / DATA / ACK / END session packet starts with a fixed
22-byte header. The session-key and client-type fields swap position by
direction — this is the #1 footgun. (PING/PONG belong to the MAC-Ping tool and
use an 18-byte variant; see Packet Types.)
Client → server layout:
Offset Len Field
0 1 version = 0x01 (always 1)
1 1 packet type (see table below)
2 6 source MAC (the client's in-protocol address)
8 6 destination MAC (the target device)
14 2 session key (uint16, BIG-endian) ← client direction
16 2 client type = 00 15 ← client direction
18 4 counter (uint32, BIG-endian)
Server → client layout is identical except the two middle fields swap:
14 2 client type = 00 15 ← server direction
16 2 session key (uint16, BIG-endian) ← server direction
So a parser must know the direction (or which MAC is "ours") to read the session
key from the right offset. Reference: init_packet() in MAC-Telnet protocol.c
keys both fields on mt_direction_fromserver.
- Version is always
1. Reject other values as not-this-protocol.
- Client type
00 15 is a fixed constant identifying a MAC-Telnet/WinBox
client. Treat it as a magic constant; its internal meaning is not
documented by MikroTik.
- Counter is cumulative payload bytes, not a packet index — see below.
Packet Types (enum mt_ptype)
| Value | Name | Direction | Purpose |
|---|
| 0 | SESSIONSTART | client → server | Open a session (header only, no payload) |
| 1 | DATA | both | Carries control blocks and/or terminal bytes; must be ACKed |
| 2 | ACK | both | Acknowledges received bytes (header only; counter = bytes seen) |
| 4 | PING | either | MAC-Ping liveness/latency probe — not the session keepalive (see below) |
| 5 | PONG | either | Reply to PING, echoing the PING's payload |
| 255 | END | either | Tear down the session |
Note the gap: there is no type 3; PING/PONG are 4/5.
PING/PONG are the separate MAC-Ping tool, not session keepalive. In the
canonical implementation a PING packet is 18 bytes, not 22: it is built like
a normal header but bytes 14–17 (the swapped session-key/client-type fields) are
zeroed and the 4-byte counter field at 18–21 is omitted (init_pingpacket). The
responder echoes the payload back in a PONG. Do not use PING/PONG to keep a
terminal session alive — that is done with empty ACKs (see Session Lifecycle
and Reliability).
The Counter (ACK accounting — footgun)
The 32-bit counter is a running total of payload bytes, per direction —
not a sequence/packet number.
- SESSIONSTART and the first DATA use counter
0.
- Each side advances its outbound counter by the number of payload bytes it
sent (control-block bytes including their 9-byte headers, plus raw
terminal bytes).
- An ACK's counter = the received packet's counter + the length of that
packet's payload (i.e. "I have now received this many bytes"). A peer keeps
retransmitting a DATA packet until it sees an ACK whose counter reaches the
end of that payload.
- A DATA packet whose counter does not advance past what you've already
processed is a retransmission: still ACK it (so the peer stops resending) but
do not re-deliver its payload.
Getting this wrong produces a session that authenticates but then hangs on
endless retransmits, or one that double-prints terminal output.
Control Blocks (the DATA payload format)
A DATA payload is zero or more control blocks, optionally followed/mixed with
raw terminal data. Each control block:
Offset Len Field
0 4 magic = 56 34 12 FF
4 1 control type (see table)
5 4 length (uint32, BIG-endian) — byte count of value
9 N value
- The 9-byte control header is
MT_CPHEADER_LEN. The magic is
56 34 12 ff (mt_mactelnet_cpmagic).
- Length is big-endian (
htonl in the reference, even though it carries a
16-bit value).
- PLAINDATA: any bytes in the payload that do not begin with the 4-byte
magic are raw terminal data with no header. A parser reads blocks until the
next 4 bytes aren't the magic, then treats the remainder as one PLAINDATA run.
This is how keystrokes (client→server) and screen output (server→client) ride
inside DATA packets. PLAINDATA is
cptype -1 internally — never on the wire.
Note there is no escaping: terminal bytes that happen to begin with
56 34 12 ff at a parse boundary would be misread as a control block. This is
vanishingly rare for text terminals but matters for robust codec tests — the
reference parser distinguishes PLAINDATA only by the absence of the magic at
the current offset.
Control Types (enum mt_cptype)
| Value | Name | Direction | Value payload |
|---|
| 0 | BEGINAUTH | client → server | empty (length 0) — starts auth |
| 1 | PASSSALT | both (overloaded) | server→client: salt (MD5) or pubkey+salt (MTWEI). client→server (MTWEI only): username\0 + client pubkey |
| 2 | PASSWORD | client → server | MD5: 17 bytes. MTWEI: 32-byte EC-SRP proof |
| 3 | USERNAME | client → server | login name (byte string; commonly ASCII/UTF-8, no encoding is specified) |
| 4 | TERM_TYPE | client → server | $TERM (byte string, e.g. vt102, xterm) |
| 5 | TERM_WIDTH | client → server | uint16 little-endian columns |
| 6 | TERM_HEIGHT | client → server | uint16 little-endian rows |
| 7 | PACKET_ERROR | server → client | error string (byte string; auth failure, etc.) |
| 9 | END_AUTH | server → client | empty — auth done, terminal mode begins |
Note the gap: there is no control type 8; END_AUTH is 9.
Endianness trap: the control-block length is big-endian, but the terminal
width/height values are little-endian uint16. Everything else multi-byte in
the header (session key, counter) is big-endian.
Session Lifecycle
A successful client session, in order:
1. client → SESSIONSTART (counter 0, header only)
2. server → ACK
3. client → DATA: BEGINAUTH [+ MTWEI login block] (see auth below)
4. server → DATA: PASSSALT (16 bytes → MD5 mode; 49 bytes → MTWEI mode)
client → ACK (always ACK inbound DATA)
5. client → DATA: PASSWORD + USERNAME + TERM_TYPE + TERM_WIDTH + TERM_HEIGHT
6. server → DATA: END_AUTH → terminal mode
7. ── interactive: server PLAINDATA (screen) / client PLAINDATA (keystrokes),
each DATA ACKed; empty-ACK keepalive on idle (see below) ──
8. either → END → both close
Every inbound DATA is answered with an ACK (step counter = received counter +
payload length). Keepalive is an empty ACK sent on idle: the reference
client sends one after ~10 seconds of inactivity, and the server drops a session
after ~15 seconds without traffic (MT_CONNECTION_TIMEOUT). END should be
echoed/acknowledged, then the socket closed. (PING/PONG, despite the name, are
the MAC-Ping tool — not part of this keepalive.)
Authentication
The server announces the mode by the length of the PASSSALT it sends:
| Server PASSSALT length | Mode | Meaning of bytes |
|---|
| 16 | MD5 (legacy) | 16-byte random salt |
| 49 | MTWEI / EC-SRP | 33-byte server public key ‖ 16-byte salt |
Any other length is invalid — abort. This single check is the correct, robust
way to detect the mode (do not rely on RouterOS version strings).
Classic MD5
Simple and library-free:
PASSWORD value = 0x00 ‖ MD5( 0x00 ‖ password ‖ salt ) // 17 bytes total
- Leading
0x00, then the 16-byte MD5 digest of (0x00 + password bytes +
16-byte salt). The client sends only BEGINAUTH in step 3 and waits for the
server's 16-byte salt.
- Modern RouterOS no longer accepts MD5 in practice. Verified on stock CHR 7.23:
the device offers a 16-byte salt to a classic client (one that sends only
BEGINAUTH, with no MTWEI offer) but then rejects the MD5 proof even for
credentials it accepts over REST/native-API ("login failure … via
mac-telnet" in the device log). So a 16-byte salt does not mean MD5 will be
honored — you must drive MTWEI to log in. MD5 stays useful only for genuinely
legacy/downgraded gear.
MTWEI (EC-SRP over Curve25519) — current default
A zero-knowledge password proof: the password never crosses the wire and the
exchange proves the client knows it without revealing it. It requires an
elliptic-curve implementation. (Note: MTWEI authenticates the login but does
not encrypt the subsequent terminal stream — MAC-Telnet has no transport
cipher. Treat it as management-plane traffic on a trusted L2 segment.)
Wire-level flow (what differs from MD5):
- Client generates a keypair → 33-byte client public key. This is not
the standard 32-byte X25519 wire format: MTWEI runs EC-SRP on a custom
Curve25519-in-Weierstrass form and serializes the public point as a 32-byte
big-endian X coordinate plus a 1-byte Y-parity flag (literal
0/1, not a
SEC1 0x02/0x03 compressed-point tag). Use the reference MTWEI math, not
an off-the-shelf X25519 API.
- In step 3 the client sends, alongside
BEGINAUTH, a PASSSALT (type 1)
block carrying username + 0x00 + the 33-byte client public key. (Yes —
control type 1 is overloaded: server→client it is the salt; client→server in
MTWEI it is the login+pubkey.)
- Server replies with a 49-byte PASSSALT = 33-byte server public key ‖
16-byte salt.
- Client derives the SRP identity in two SHA-256 stages:
v1 = SHA256(username ‖ ":" ‖ password), then
id = SHA256(salt ‖ v1) (16-byte salt). It runs the EC-SRP exchange over
both public keys using id to produce a 32-byte PASSWORD proof, sent with
USERNAME + terminal blocks as in MD5.
- Server verifies; on success sends END_AUTH.
Username caveat: the C reference strips any +... console-parameter suffix from
the login name only for the MTWEI identity hash (strsep(..., "+") before
mtwei_id()), while still sending the original login string in the USERNAME
control block. Implement this if you support RouterOS console parameters in the
login field.
The cryptographic core (point arithmetic, Weierstrass↔Montgomery conversion,
proof derivation) is non-trivial — implement against a reference rather than
from this summary:
- C:
mtwei.c / mtwei.h in haakonnessjoen/MAC-Telnet (OpenSSL EC;
EC-SRP per the IEEE P1363.2 draft, derived from Margin Research's PoC).
- .NET:
EcsrpEngine.cs / EcsrpMath.cs in KCTech-Lab/KC.MacTelnet.
- m2ir:
profiles/winbox-ipc/ec-srp5-handshake.yaml captures the related
WinBox EC-SRP5 point encoding and is useful corroboration for the 32-byte-X +
parity shape. Do not copy WinBox's later AES-128-CBC transport encryption
into MAC-Telnet; MAC-Telnet's terminal stream is not encrypted.
Constants worth pinning in tests: client/server public key = 33 bytes
(MTWEI_PUBKEY_LEN), validator/proof = 32 bytes (MTWEI_VALIDATOR_LEN).
Auth-mode pragmatics
To log into current RouterOS you must implement MTWEI — an MD5-only client
gets a 16-byte salt but its proof is refused (see Classic MD5 above). A robust
client offers MTWEI in BEGINAUTH by default and falls back to MD5 only when the
device returns a 16-byte salt (legacy gear). tikoci/centrs does this — it
implements both MD5 and MTWEI (src/protocols/mtwei.ts, a dependency-free
BigInt port of mtwei.c) and is validated over real L2 against stock CHR 7.23.
END_AUTH does not mean the login succeeded. A failed login also sends
END_AUTH, immediately followed by a PLAINDATA "Login failed, incorrect
username or password" message and END. Confirm success only when real terminal
output (a prompt/banner) arrives; treat END_AUTH → "Login failed" → END (or
an END right after END_AUTH with no prompt) as auth failure. Grounded on CHR
7.23 via centrs.
After Login: The Interactive Console
END_AUTH drops you into RouterOS's normal readline console — the same shell you
get over SSH/serial. These facts are grounded on stock CHR 7.23.1 (via
tikoci/centrs's console reader) and matter whenever you scrape command output
over MAC-Telnet rather than just relaying to a human's terminal:
- Terminal-size probe. On connect the console runs a multi-step ANSI cursor
probe to learn the terminal size:
ESC[9999B / ESC[9999C (move the cursor far
down / right), ESC[H (home), ESC D, ESC Z (DECID), mode sets
(ESC[4l ESC[20l ESC[?7h ESC[?5l ESC[?25h), and repeated ESC[6n (DSR
cursor-position report). Answer each ESC[6n with ESC[<row>;<col>R.
Reporting a wide column makes the console use that width; ignore the probe and
it falls back to 80 columns and wraps the echo, which breaks naive output
scraping. The TERM_WIDTH/TERM_HEIGHT control blocks appear advisory — the ANSI
probe wins.
- ~10s negotiation stall. The MOTD/prompt does not appear until ~10 seconds
after login, on every login. Answering the DSR sets the width but does not
remove the stall — the probe seems to expect a real terminal's clamped cursor
tracking across the down/up/right/home sequence, so a fixed DSR answer leaves it
unsatisfied and the console waits out a timeout. A cursor-tracking emulator
(track row/col in a rows×cols grid, answer each DSR with the real clamped
position) is the way to avoid it. A human with a real terminal does not see this.
- First-login license gate. A factory-fresh device prints the banner and
Do you want to see the software license? [Y/n]: and blocks until answered —
exactly the fresh/unconfigured gear MAC-Telnet exists for, so a robust client
must detect and answer it (n). Later logins skip it.
- Prompt, echo, and output shape. The prompt has two forms:
[user@identity] > (root) and
[user@identity] /path> (submenu), each with a trailing space; it is redrawn with CR + space-padding + CR (not
ANSI erase). Keystrokes are echoed and the input line is redrawn. Command
responses contain no ANSI — only CR, LF, spaces, and text — so a simple CR/LF
terminal emulation (CR → column 0 with overwrite, LF → new line) reconstructs the
screen. A response is: the echoed [prompt] > <command> line, then the output
lines, then the trailing prompt; strip the first and last to get clean output. A
successful write prints nothing (e.g. /ip/address/add … returns straight to
the prompt — there is no .id on the console, unlike REST/the binary API).
- Validate without running:
:put [:parse "<command>"] over the console prints
(evl …) for a good parse, syntax error (line N column M) for malformed CLI,
and bad parameter <name> (line …) for an unknown attribute — so one console
:parse checks both syntax and parameter validity. (Over REST the same string
rides an HTTP-200 ret; over the binary API :put [:parse] returns an opaque
object handle and reveals nothing.)
Reliability & Retransmission
UDP provides nothing, so the protocol layers on:
- ACK-by-byte-counter (above) — a sender retransmits an unacknowledged DATA.
- Timed retransmission schedule from the reference client, in milliseconds:
{15, 20, 30, 50, 90, 170, 330, 660, 1000} (retransmit_intervals), i.e. a
rough exponential backoff, up to 9 tries before giving up.
- Empty-ACK keepalive so idle authenticated sessions are not dropped: the
client sends an empty ACK after ~10s idle; the server times out a session
after ~15s of silence. (PING/PONG is the MAC-Ping tool, not this keepalive.)
Transport & Socket Notes
- Server: UDP 20561. To receive frames addressed to a device that has no
IP, real clients often send to the broadcast address (or a raw Ethernet
frame to the unicast target MAC) and rely on the in-packet destination MAC to
let the right device claim the session. The device replies to the client MAC.
- Because addressing is in-band, you generally do not need the target's IP —
only its MAC (discovered via MNDP, ARP, or printed on the label).
- A shared L2 segment means other devices' MAC-Telnet traffic can hit your
socket. Filter rigorously: accept a packet only when version = 1, the
session key matches, and the in-packet src/dst MACs mirror your own (their src
= your dst, their dst = your src). Drop everything else.
- Self-echo: broadcasting can loop your own packet back; ignore packets whose
in-packet source MAC is yours.
- Privilege: sending raw Ethernet frames (the most robust delivery) needs
root /
CAP_NET_RAW. A pure UDP-broadcast approach avoids that but is less
universal.
- Testing without a router: the codec and state machine are pure functions
of bytes — drive them with a scripted in-memory peer (no L2 fabric needed), as
centrs' unit tests do. Full end-to-end testing needs a real L2 segment
(CHR-on-a-bridge or hardware), since broadcast/raw-frame delivery is the part a
CI runner usually can't provide.
Security Considerations
- MD5 auth is weak — salted MD5 of the password, no forward secrecy, and the
17-byte proof is replayable within a salt. Prefer MTWEI; disable legacy MD5
login on RouterOS unless required for old gear.
- MTWEI does not transmit the password, but MAC-Telnet does not use MTWEI as
a transport cipher for the terminal stream — treat it as a management-plane
protocol for trusted L2 segments.
- No transport authentication of the peer beyond the auth exchange; any
device on the segment can attempt sessions. Restrict MAC-Telnet on RouterOS
(
/tool mac-server) to trusted interfaces.
- Exposure: a reachable L2 segment + a weak password = shell. Lock down the
MAC-server interface list the same way you would Telnet/SSH.
RouterOS-Side Surface
# Which interfaces accept incoming MAC-Telnet (server side)
/tool/mac-server/print
/tool/mac-server/set allowed-interface-list=LAN
# MAC WinBox (related L2 service) is configured separately
/tool/mac-server/mac-winbox/print
# From one RouterOS device to another, by MAC:
/tool/mac-telnet 64:D1:54:XX:XX:XX
Restricting allowed-interface-list to a trusted list is the primary hardening
control; the default may allow all interfaces.
Reference Implementations
| Lang | Source | Notes |
|---|
| C | haakonnessjoen/MAC-Telnet (protocol.c/.h, mactelnet.c, mactelnetd.c, mtwei.c/.h) | Canonical ground truth. Header offsets, control magic, MD5 + MTWEI. MTWEI added 2022. |
| .NET 10 | KCTech-Lab/KC.MacTelnet (MacTelnetDriver/Proto/*, Proto/Auth/Ecsrp*) | Modern client; implements MTWEI against current RouterOS 7.x; pluggable terminal engine. |
| TypeScript | tikoci/centrs (src/protocols/mac-telnet.ts, src/protocols/mtwei.ts, src/protocols/mac-telnet-console.ts) | Pure codec + injectable-sink session state machine with byte-counter retransmit + empty-ACK keepalive (a tick(now) driven by the console reader); MD5 + MTWEI (dependency-free BigInt EC-SRP port of mtwei.c); interactive-console reader (terminal-probe answering, license clear, prompt sync, CR/LF screen emulation, :parse gate) wired into an execute adapter; scripted-peer + real-byte unit tests and real-L2 CHR-7.23.1 integration (login, run, write, validate) via quickchr socket-connect. |
Verified: the header layout (22 bytes, direction-swapped session-key/client-
type), control-block magic 56 34 12 ff, big-endian length, little-endian
terminal dimensions, packet/control type enums, the 17-byte MD5 proof, the
16-vs-49-byte PASSSALT mode detection, the MTWEI identity hash, and the 33-byte
public-point encoding in this skill were cross-checked against MAC-Telnet's
protocol.c / mactelnet.c / mtwei.c, the centrs codec + tests, and
m2ir's related EC-SRP5 profile.
Provenance & Unknowns
Captured deliberately, with confidence noted:
- High confidence (source-grounded): port 20561, the 22-byte header and its
direction swap, control magic, packet/control type numbers, byte-counter ACK
semantics, the empty-ACK keepalive (~10s client / ~15s server timeout), MD5
proof formula, the two-stage MTWEI identity hash, the 16-vs-49 PASSSALT
detection, the MTWEI public-key (33) / proof (32) sizes, and the 32-byte-X +
parity point encoding — all read directly from
protocol.c, mactelnet.c,
mactelnetd.c, and mtwei.c, with key codec behavior cross-checked against
centrs and the point encoding corroborated by m2ir's WinBox EC-SRP5
profile.
- C-reference-specific behavior: MTWEI strips a
+... console-parameter
suffix from the login name before hashing the SRP identity, but still sends
the original login string in USERNAME. This is grounded in mactelnet.c;
verify against your target implementation if you depend on console
parameters.
- Meaning of
client type 00 15: treated as an opaque constant. MikroTik
publishes no spec; do not attribute semantics to it.
- MTWEI math details (curve constants, Weierstrass↔Montgomery conversion,
proof derivation): not reproduced here on purpose — they are easy to get
subtly wrong, and the 33-byte public key is not standard X25519. Implement
against
mtwei.c or EcsrpEngine.cs and pin the byte-length constants plus
the 32-byte-X + parity encoding in tests.
- PING/PONG (MAC-Ping): present in the enum (4/5) and used by the MAC-Ping
latency tool with 18-byte packets; the exact MAC-Ping payload semantics are
out of scope here and only sketched.
- Exact UDP delivery (broadcast vs. raw unicast Ethernet, source port choice):
varies by implementation and platform; the in-packet MAC addressing is the
invariant. Verify delivery empirically on your target segment.
- There is no official MikroTik protocol specification. All of the above is
reverse-engineered and corroborated across the reference implementations cited.
Related Skills
routeros-mndp — discover the device and MAC to connect to (UDP 5678).
routeros-fundamentals — RouterOS CLI/REST basics for what you do once
connected.
routeros-qemu-chr — boot CHR (e.g. bridged onto an L2 segment) to test
MAC-Telnet without hardware.
routeros-sniffer — capture and inspect MAC-Telnet packets on the wire.