MAC-Telnet — MikroTik Layer-2 Terminal Protocol

SkillSecurity

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.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the MAC-Telnet — MikroTik Layer-2 Terminal Protocol skill

What this skill tells your AI

The instructions your AI receives, as published by tikoci/routeros-skills in routeros-mac-telnet/SKILL.md and read by ahel’s review.

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

PropertyValue
TransportUDP
Port20561 (server listens; client may use an ephemeral or matching source port)
AddressingIn-packet 6-byte src + 6-byte dst MAC (the real addressing); outer UDP/IP is just transport
DeliveryLayer-2 — broadcast or unicast Ethernet frame to the target MAC; does not cross routers
ReliabilityApplication-level: byte-counter ACKs + timed retransmission (UDP gives none)
SessionStateful handshake → authenticated → raw terminal stream → teardown
AuthMD5 (legacy) or MTWEI / EC-SRP (current RouterOS 7.x default)
Max packet1500 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)

ValueNameDirectionPurpose
0SESSIONSTARTclient → serverOpen a session (header only, no payload)
1DATAbothCarries control blocks and/or terminal bytes; must be ACKed
2ACKbothAcknowledges received bytes (header only; counter = bytes seen)
4PINGeitherMAC-Ping liveness/latency probe — not the session keepalive (see below)
5PONGeitherReply to PING, echoing the PING's payload
255ENDeitherTear 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)

ValueNameDirectionValue payload
0BEGINAUTHclient → serverempty (length 0) — starts auth
1PASSSALTboth (overloaded)server→client: salt (MD5) or pubkey+salt (MTWEI). client→server (MTWEI only): username\0 + client pubkey
2PASSWORDclient → serverMD5: 17 bytes. MTWEI: 32-byte EC-SRP proof
3USERNAMEclient → serverlogin name (byte string; commonly ASCII/UTF-8, no encoding is specified)
4TERM_TYPEclient → server$TERM (byte string, e.g. vt102, xterm)
5TERM_WIDTHclient → serveruint16 little-endian columns
6TERM_HEIGHTclient → serveruint16 little-endian rows
7PACKET_ERRORserver → clienterror string (byte string; auth failure, etc.)
9END_AUTHserver → clientempty — 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 lengthModeMeaning of bytes
16MD5 (legacy)16-byte random salt
49MTWEI / EC-SRP33-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):

  1. 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.
  2. 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.)
  3. Server replies with a 49-byte PASSSALT = 33-byte server public key ‖ 16-byte salt.
  4. 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.
  5. 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

LangSourceNotes
Chaakonnessjoen/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 10KCTech-Lab/KC.MacTelnet (MacTelnetDriver/Proto/*, Proto/Auth/Ecsrp*)Modern client; implements MTWEI against current RouterOS 7.x; pluggable terminal engine.
TypeScripttikoci/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:

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
59
Forks
14
Last commit
Jul 2026
Advanced
Catalog kind
skill
Gateway key
routeros-mac-telnet
Source
github.com/tikoci/routeros-skills