Key findings: - btest EC-SRP5 uses [len][payload] framing (NO 0x06 handler byte) - Winbox uses [len][0x06][payload] — that one byte was the difference - Crypto is identical: Curve25519 Weierstrass, SHA256, SRP-like key exchange - Python prototype successfully authenticates against MikroTik RouterOS 7.x Files: - docs/ecsrp5-research.md: complete protocol spec, captured exchange, impl plan - proto-test/btest_ecsrp5_client.py: working Python EC-SRP5 btest client - proto-test/btest_mitm.py: MITM proxy used to discover the framing - proto-test/elliptic_curves.py: Curve25519 Weierstrass (from MarginResearch) Based on MarginResearch/mikrotik_authentication (MIT License). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
189 lines
6.1 KiB
Python
189 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
MITM proxy for btest protocol.
|
|
Sits between MikroTik client and MikroTik server, logs all bytes exchanged.
|
|
|
|
Usage:
|
|
python3 btest_mitm.py --listen 2000 --target 172.16.81.1:2000
|
|
|
|
Then point MikroTik client at THIS machine's IP on port 2000.
|
|
"""
|
|
import socket
|
|
import select
|
|
import sys
|
|
import argparse
|
|
import time
|
|
import threading
|
|
|
|
|
|
def hexdump_line(data, offset=0):
|
|
"""Format a single line of hex dump."""
|
|
hex_part = " ".join(f"{b:02x}" for b in data)
|
|
ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in data)
|
|
return f" {offset:04x} {hex_part:<48s} {ascii_part}"
|
|
|
|
|
|
def hexdump(label, data):
|
|
"""Pretty hex dump with ASCII."""
|
|
ts = time.strftime("%H:%M:%S", time.localtime())
|
|
print(f"\n[{ts}] {label} ({len(data)} bytes)")
|
|
for i in range(0, len(data), 16):
|
|
print(hexdump_line(data[i : i + 16], i))
|
|
sys.stdout.flush()
|
|
|
|
|
|
def annotate_btest(direction, data, state):
|
|
"""Try to annotate known btest protocol messages."""
|
|
if len(data) == 4:
|
|
val = data.hex()
|
|
if val == "01000000":
|
|
return "HELLO / AUTH_OK"
|
|
elif val == "02000000":
|
|
return "AUTH_REQUIRED (MD5)"
|
|
elif val == "03000000":
|
|
state["auth_type"] = "ecsrp5"
|
|
return "AUTH_REQUIRED (EC-SRP5)"
|
|
elif val == "00000000":
|
|
return "AUTH_FAILED"
|
|
|
|
if len(data) == 16 and state.get("stage") == "command":
|
|
proto = "UDP" if data[0] == 0 else "TCP"
|
|
dirs = {1: "RX", 2: "TX", 3: "BOTH"}
|
|
d = dirs.get(data[1], f"0x{data[1]:02x}")
|
|
conn = data[3]
|
|
return f"COMMAND: proto={proto} dir={d} conn_count={conn}"
|
|
|
|
if len(data) == 2 and state.get("auth_type") == "ecsrp5":
|
|
return f"UDP port assignment: {int.from_bytes(data, 'big')}"
|
|
|
|
if state.get("auth_type") == "ecsrp5" and state.get("stage") == "ecsrp5":
|
|
if len(data) >= 33:
|
|
return f"EC-SRP5 message (likely pubkey + parity/salt)"
|
|
|
|
return None
|
|
|
|
|
|
def proxy_connection(client_sock, client_addr, target_host, target_port):
|
|
"""Handle one proxied connection."""
|
|
print(f"\n{'='*60}")
|
|
print(f"New connection from {client_addr[0]}:{client_addr[1]}")
|
|
print(f"Proxying to {target_host}:{target_port}")
|
|
print(f"{'='*60}")
|
|
|
|
try:
|
|
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
server_sock.settimeout(30)
|
|
server_sock.connect((target_host, target_port))
|
|
server_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
except Exception as e:
|
|
print(f"Failed to connect to target: {e}")
|
|
client_sock.close()
|
|
return
|
|
|
|
state = {"stage": "hello", "msg_count": 0}
|
|
|
|
try:
|
|
while True:
|
|
readable, _, _ = select.select(
|
|
[client_sock, server_sock], [], [], 30
|
|
)
|
|
|
|
if not readable:
|
|
print("\n[TIMEOUT] No data for 30 seconds")
|
|
break
|
|
|
|
for sock in readable:
|
|
if sock is server_sock:
|
|
data = server_sock.recv(65536)
|
|
if not data:
|
|
print("\n[SERVER CLOSED]")
|
|
return
|
|
direction = "SERVER → CLIENT"
|
|
annotation = annotate_btest("s2c", data, state)
|
|
hexdump(direction, data)
|
|
if annotation:
|
|
print(f" >>> {annotation}")
|
|
|
|
# Track state
|
|
if state["stage"] == "hello" and len(data) == 4:
|
|
state["stage"] = "command"
|
|
elif state["stage"] == "auth_resp":
|
|
state["stage"] = "ecsrp5"
|
|
|
|
# Forward to client
|
|
client_sock.sendall(data)
|
|
|
|
elif sock is client_sock:
|
|
data = client_sock.recv(65536)
|
|
if not data:
|
|
print("\n[CLIENT CLOSED]")
|
|
return
|
|
direction = "CLIENT → SERVER"
|
|
annotation = annotate_btest("c2s", data, state)
|
|
hexdump(direction, data)
|
|
if annotation:
|
|
print(f" >>> {annotation}")
|
|
|
|
# Track state
|
|
if state["stage"] == "command" and len(data) == 16:
|
|
state["stage"] = "auth_resp"
|
|
state["msg_count"] += 1
|
|
|
|
# Forward to server
|
|
server_sock.sendall(data)
|
|
|
|
except (ConnectionResetError, BrokenPipeError) as e:
|
|
print(f"\n[CONNECTION ERROR] {e}")
|
|
except Exception as e:
|
|
print(f"\n[ERROR] {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
finally:
|
|
client_sock.close()
|
|
server_sock.close()
|
|
print(f"\n{'='*60}")
|
|
print(f"Connection closed ({state['msg_count']} client messages)")
|
|
print(f"{'='*60}")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="btest MITM proxy")
|
|
parser.add_argument(
|
|
"-l", "--listen", type=int, default=2000, help="Listen port (default: 2000)"
|
|
)
|
|
parser.add_argument(
|
|
"-t",
|
|
"--target",
|
|
required=True,
|
|
help="Target MikroTik address:port (e.g., 172.16.81.1:2000)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
target_parts = args.target.split(":")
|
|
target_host = target_parts[0]
|
|
target_port = int(target_parts[1]) if len(target_parts) > 1 else 2000
|
|
|
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
listener.bind(("0.0.0.0", args.listen))
|
|
listener.listen(5)
|
|
|
|
print(f"MITM proxy listening on 0.0.0.0:{args.listen}")
|
|
print(f"Forwarding to {target_host}:{target_port}")
|
|
print(f"Point MikroTik client at this machine's IP, port {args.listen}")
|
|
print()
|
|
|
|
while True:
|
|
client_sock, client_addr = listener.accept()
|
|
client_sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
|
t = threading.Thread(
|
|
target=proxy_connection,
|
|
args=(client_sock, client_addr, target_host, target_port),
|
|
daemon=True,
|
|
)
|
|
t.start()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|