Research: EC-SRP5 authentication fully reverse-engineered

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>
This commit is contained in:
Siavash Sameni
2026-03-31 16:33:07 +04:00
parent 8fe4e72bb3
commit afe389ce7e
6 changed files with 812 additions and 2 deletions

View File

@@ -0,0 +1,235 @@
#!/usr/bin/env python3
"""
btest EC-SRP5 authentication test client.
Connects to a MikroTik btest server (RouterOS >= 6.43) and performs
the EC-SRP5 handshake to authenticate.
Usage:
python3 btest_ecsrp5_client.py -a 172.16.81.1 -u admin -p password
python3 btest_ecsrp5_client.py -a 172.16.81.1 -u admin -p password --receive
"""
import socket
import secrets
import hashlib
import argparse
import struct
import sys
import time
import elliptic_curves
BTEST_PORT = 2000
def sha256(data: bytes) -> bytes:
return hashlib.sha256(data).digest()
def hexdump(label: str, data: bytes):
print(f" {label} ({len(data)} bytes): {data.hex()}")
class BtestECSRP5Client:
def __init__(self, host: str, port: int = BTEST_PORT):
self.host = host
self.port = port
self.w = elliptic_curves.WCurve()
self.sock = None
def connect(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(10)
self.sock.connect((self.host, self.port))
self.sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
print(f"Connected to {self.host}:{self.port}")
def recv_exact(self, n: int) -> bytes:
buf = b""
while len(buf) < n:
chunk = self.sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("Connection closed")
buf += chunk
return buf
def do_hello_and_command(self, proto=1, direction=2, conn_count=0, tx_size=0x8000):
"""Send command, return auth response type."""
# Receive HELLO
hello = self.recv_exact(4)
hexdump("HELLO", hello)
assert hello == b"\x01\x00\x00\x00", f"Bad HELLO: {hello.hex()}"
# Send command
cmd = struct.pack("<BBBBHHII",
proto, direction, 0, conn_count,
tx_size, 0, 0, 0)
hexdump("CMD", cmd)
self.sock.sendall(cmd)
# Receive auth response
resp = self.recv_exact(4)
hexdump("AUTH_RESP", resp)
return resp
def do_ecsrp5_auth(self, username: str, password: str):
"""Perform EC-SRP5 authentication after receiving 03 00 00 00."""
print("\n=== EC-SRP5 Authentication ===")
# Step 1: Generate client ephemeral keypair
s_a = secrets.token_bytes(32)
x_w_a, x_w_a_parity = self.w.gen_public_key(s_a)
print(f" Client private: {s_a.hex()}")
hexdump("Client pubkey (x_w_a)", x_w_a)
print(f" Client parity: {x_w_a_parity}")
# Step 2: Send client public key + username
# btest format: [len][username\0][pubkey:32][parity:1] (NO 0x06 handler byte)
payload = username.encode("utf-8") + b"\x00" + x_w_a + bytes([x_w_a_parity])
msg = bytes([len(payload)]) + payload
hexdump("MSG1 (client pubkey)", msg)
self.sock.sendall(msg)
# Step 3: Receive server response
# btest format: [len][server_pubkey:32][parity:1][salt:16] (NO 0x06)
resp = self.sock.recv(1024)
hexdump("MSG2 (server challenge)", resp)
if len(resp) < 2:
print("ERROR: Empty server response")
return False
resp_len = resp[0]
resp_data = resp[1:]
if len(resp_data) != resp_len:
print(f"WARNING: Expected {resp_len} bytes, got {len(resp_data)}")
x_w_b = resp_data[:32]
x_w_b_parity = resp_data[32]
salt = resp_data[33:]
hexdump("Server pubkey (x_w_b)", x_w_b)
print(f" Server parity: {x_w_b_parity}")
hexdump("Salt", salt)
if len(salt) != 16:
print(f"ERROR: Expected 16-byte salt, got {len(salt)}")
return False
# Step 4: Compute shared secret (ECPESVDP-SRP-A)
i = self.w.gen_password_validator_priv(username, password, salt)
x_gamma, gamma_parity = self.w.gen_public_key(i)
v = self.w.redp1(x_gamma, 1) # password verifier point
# Recover server's actual public point (undo verifier blinding)
w_b = self.w.lift_x(int.from_bytes(x_w_b, "big"), x_w_b_parity)
w_b = w_b + v # w_b + V
# Compute combined hash
j = sha256(x_w_a + x_w_b)
# Compute combined scalar: (i * j + s_a) mod r
scalar = int.from_bytes(i, "big") * int.from_bytes(j, "big")
scalar += int.from_bytes(s_a, "big")
scalar = self.w.finite_field_value(scalar)
# Shared secret point
Z = scalar * w_b
z, _ = self.w.to_montgomery(Z)
hexdump("Shared secret (z)", z)
# Step 5: Send client confirmation code
# btest format: [len][cc:32] (NO 0x06)
client_cc = sha256(j + z)
msg3 = bytes([len(client_cc)]) + client_cc
hexdump("MSG3 (client proof)", msg3)
self.sock.sendall(msg3)
# Step 6: Receive server confirmation
resp2 = self.sock.recv(1024)
hexdump("MSG4 (server proof)", resp2)
if len(resp2) < 2:
print("ERROR: Invalid server proof response")
return False
# btest format: [len][sc:32] (NO 0x06)
server_cc_received = resp2[1:]
server_cc_expected = sha256(j + client_cc + z)
if server_cc_received == server_cc_expected:
print("\n=== EC-SRP5 Authentication SUCCESSFUL ===")
return True
else:
print("\n=== EC-SRP5 Authentication FAILED ===")
hexdump("Expected server_cc", server_cc_expected)
hexdump("Received server_cc", server_cc_received)
return False
def run_test(self, username: str, password: str, direction: str = "receive"):
self.connect()
# Direction from server perspective:
# 0x01 = server RX (client TX / "send")
# 0x02 = server TX (client RX / "receive")
dir_byte = 0x02 if direction == "receive" else 0x01
resp = self.do_hello_and_command(proto=1, direction=dir_byte)
if resp == b"\x01\x00\x00\x00":
print("No auth required - server accepted without authentication")
return True
elif resp == b"\x02\x00\x00\x00":
print("MD5 auth required (old RouterOS)")
return False
elif resp == b"\x03\x00\x00\x00":
print("EC-SRP5 auth required (RouterOS >= 6.43)")
return self.do_ecsrp5_auth(username, password)
else:
print(f"Unknown auth response: {resp.hex()}")
return False
def main():
parser = argparse.ArgumentParser(description="btest EC-SRP5 auth test client")
parser.add_argument("-a", "--address", required=True, help="MikroTik IP address")
parser.add_argument("-u", "--username", default="admin", help="Username")
parser.add_argument("-p", "--password", default="", help="Password")
parser.add_argument("-P", "--port", type=int, default=BTEST_PORT, help="Port")
parser.add_argument("--receive", action="store_true", help="Test receive direction")
args = parser.parse_args()
direction = "receive" if args.receive else "receive"
client = BtestECSRP5Client(args.address, args.port)
try:
success = client.run_test(args.username, args.password, direction)
if success:
print("\nAuth passed! Reading some data...")
try:
data = client.sock.recv(4096)
hexdump("First data received", data[:64])
print(f" (total {len(data)} bytes)")
# Read for a few seconds to confirm data flows
client.sock.settimeout(2)
total = len(data)
for _ in range(5):
try:
data = client.sock.recv(65536)
total += len(data)
except socket.timeout:
break
print(f"\nTotal received: {total} bytes ({total * 8 / 1_000_000:.2f} Mbps over ~2s)")
except Exception as e:
print(f"Data read error: {e}")
sys.exit(0 if success else 1)
except Exception as e:
print(f"Error: {e}")
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()

188
proto-test/btest_mitm.py Normal file
View File

@@ -0,0 +1,188 @@
#!/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()

View File

@@ -0,0 +1,138 @@
"""
Elliptic curve implementation for MikroTik EC-SRP5 authentication.
Based on MarginResearch/mikrotik_authentication (MIT License).
Curve25519 in Weierstrass form.
"""
import hashlib
import ecdsa
def _egcd(a, b):
if a == 0:
return (b, 0, 1)
else:
g, y, x = _egcd(b % a, a)
return (g, x - (b // a) * y, y)
def _modinv(a: int, p: int):
if a < 0:
a = a % p
g, x, y = _egcd(a, p)
if g != 1:
raise Exception("modular inverse does not exist")
return x % p
def _legendre_symbol(a: int, p: int):
l = pow(a, (p - 1) // 2, p)
if l == p - 1:
return -1
return l
def _prime_mod_sqrt(a: int, p: int):
a %= p
if a == 0:
return [0]
if p == 2:
return [a]
if _legendre_symbol(a, p) != 1:
return []
if p % 4 == 3:
x = pow(a, (p + 1) // 4, p)
return [x, p - x]
q, s = p - 1, 0
while q % 2 == 0:
s += 1
q //= 2
z = 1
while _legendre_symbol(z, p) != -1:
z += 1
c = pow(z, q, p)
x = pow(a, (q + 1) // 2, p)
t = pow(a, q, p)
m = s
while t != 1:
i, e = 0, 2
for i in range(1, m):
if pow(t, e, p) == 1:
break
e *= 2
b = pow(c, 2 ** (m - i - 1), p)
x = (x * b) % p
t = (t * b * b) % p
c = (b * b) % p
m = i
return [x, p - x]
class WCurve:
def __init__(self):
self.__p = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFED
self.__r = 0x1000000000000000000000000000000014DEF9DEA2F79CD65812631A5CF5D3ED
self.__mont_a = 486662
self.__conversion_from_m = self.__mont_a * _modinv(3, self.__p) % self.__p
self.__conversion = (self.__p - self.__mont_a * _modinv(3, self.__p)) % self.__p
self.__a = 0x2AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA984914A144
self.__b = 0x7B425ED097B425ED097B425ED097B425ED097B425ED097B4260B5E9C7710C864
self.__h = 8
self.__curve = ecdsa.ellipticcurve.CurveFp(self.__p, self.__a, self.__b, self.__h)
self.__g = self.lift_x(9, 0)
def gen_public_key(self, priv: bytes):
assert len(priv) == 32
priv = int.from_bytes(priv, "big")
pt = priv * self.__g
return self.to_montgomery(pt)
def to_montgomery(self, pt):
x = (pt.x() + self.__conversion) % self.__p
return int(x).to_bytes(32, "big"), pt.y() & 1
def lift_x(self, x: int, parity: bool):
x = x % self.__p
y_squared = (x**3 + self.__mont_a * x**2 + x) % self.__p
x += self.__conversion_from_m
x %= self.__p
ys = _prime_mod_sqrt(y_squared, self.__p)
if ys != []:
pt1 = ecdsa.ellipticcurve.PointJacobi(self.__curve, x, ys[0], 1, self.__r)
pt2 = ecdsa.ellipticcurve.PointJacobi(self.__curve, x, ys[1], 1, self.__r)
if pt1.y() & 1 == 1 and parity != 0:
return pt1
elif pt2.y() & 1 == 1 and parity != 0:
return pt2
elif pt1.y() & 1 == 0 and parity == 0:
return pt1
else:
return pt2
else:
return -1
def redp1(self, x: bytes, parity: bool):
x = hashlib.sha256(x).digest()
while True:
x2 = hashlib.sha256(x).digest()
pt = self.lift_x(int.from_bytes(x2, "big"), parity)
if pt == -1:
x = (int.from_bytes(x, "big") + 1).to_bytes(32, "big")
else:
break
return pt
def gen_password_validator_priv(self, username: str, password: str, salt: bytes):
assert len(salt) == 0x10
return hashlib.sha256(
salt + hashlib.sha256((username + ":" + password).encode("utf-8")).digest()
).digest()
def multiply_by_g(self, a: int):
return a * self.__g
def finite_field_value(self, a: int):
return a % self.__r