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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,3 +3,5 @@
|
||||
btest_original
|
||||
.claude/
|
||||
.env
|
||||
proto-test/venv/
|
||||
proto-test/__pycache__/
|
||||
|
||||
@@ -50,12 +50,20 @@ sequenceDiagram
|
||||
|
||||
alt No auth configured
|
||||
SRV->>TCP: AUTH_OK [01 00 00 00]
|
||||
else MD5 auth
|
||||
else MD5 auth (RouterOS < 6.43)
|
||||
SRV->>TCP: AUTH_REQUIRED [02 00 00 00]
|
||||
SRV->>TCP: Challenge [16 random bytes]
|
||||
MK->>TCP: Response [16 hash + 32 username]
|
||||
Note over SRV: Verify MD5(pass + MD5(pass + challenge))
|
||||
SRV->>TCP: AUTH_OK or AUTH_FAILED
|
||||
else EC-SRP5 auth (RouterOS >= 6.43)
|
||||
SRV->>TCP: EC-SRP5 [03 00 00 00]
|
||||
MK->>TCP: [len][username\0][client_pubkey:32][parity:1]
|
||||
SRV->>TCP: [len][server_pubkey:32][parity:1][salt:16]
|
||||
MK->>TCP: [len][client_proof:32]
|
||||
SRV->>TCP: [len][server_proof:32]
|
||||
Note over SRV: Curve25519 Weierstrass EC-SRP5<br/>See docs/ecsrp5-research.md
|
||||
SRV->>TCP: AUTH_OK [01 00 00 00]
|
||||
end
|
||||
|
||||
alt TCP mode
|
||||
@@ -178,7 +186,7 @@ btest-rs/
|
||||
│ ├── main.rs # CLI entry point, argument parsing
|
||||
│ ├── lib.rs # Public API (used by integration tests)
|
||||
│ ├── protocol.rs # Wire format: Command, StatusMessage, constants
|
||||
│ ├── auth.rs # MD5 challenge-response authentication
|
||||
│ ├── auth.rs # Authentication (MD5 + EC-SRP5)
|
||||
│ ├── server.rs # Server mode: listener, TCP/UDP handlers
|
||||
│ ├── client.rs # Client mode: connector, TCP/UDP handlers
|
||||
│ └── bandwidth.rs # Rate limiting, formatting, shared state
|
||||
@@ -193,6 +201,7 @@ btest-rs/
|
||||
├── docs/
|
||||
│ ├── architecture.md # This file
|
||||
│ ├── protocol.md # Protocol specification
|
||||
│ ├── ecsrp5-research.md # EC-SRP5 reverse-engineering findings
|
||||
│ ├── user-guide.md # Usage documentation
|
||||
│ └── docker.md # Docker & deployment guide
|
||||
├── Dockerfile # Production Docker image
|
||||
|
||||
238
docs/ecsrp5-research.md
Normal file
238
docs/ecsrp5-research.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# EC-SRP5 Authentication Research
|
||||
|
||||
## Summary
|
||||
|
||||
MikroTik RouterOS >= 6.43 uses EC-SRP5 (Elliptic Curve Secure Remote Password) for authentication. When the btest server has auth enabled, it responds with `03 00 00 00` instead of `02 00 00 00` (legacy MD5).
|
||||
|
||||
**Status: Fully reverse-engineered and verified.** Python prototype authenticates successfully against MikroTik RouterOS 7.x btest server.
|
||||
|
||||
## Discovery Process
|
||||
|
||||
### Step 1: Initial Capture
|
||||
|
||||
Connected our client to MikroTik btest server with auth enabled. Server responded with `03 00 00 00` and waited for the client to initiate.
|
||||
|
||||
### Step 2: Winbox EC-SRP5 Verification
|
||||
|
||||
Tested the EC-SRP5 crypto implementation (from [MarginResearch/mikrotik_authentication](https://github.com/MarginResearch/mikrotik_authentication)) against MikroTik's Winbox port (8291). **Authentication succeeded**, confirming the elliptic curve math is correct.
|
||||
|
||||
### Step 3: Framing Discovery via MITM
|
||||
|
||||
The Winbox `[len][0x06][payload]` framing was rejected by the btest port. To discover the correct framing, we built a MITM proxy (`proto-test/btest_mitm.py`) and routed a MikroTik client through it to the MikroTik server.
|
||||
|
||||
**Finding: btest uses `[len][payload]` — identical to Winbox but without the `0x06` handler byte.**
|
||||
|
||||
### Step 4: Successful Authentication
|
||||
|
||||
Updated the Python prototype to use `[len][payload]` framing. EC-SRP5 authentication against MikroTik's btest server succeeded and data transfer began.
|
||||
|
||||
## Protocol Specification
|
||||
|
||||
### Auth Trigger
|
||||
|
||||
After the standard btest handshake (HELLO + Command), the server responds:
|
||||
|
||||
```
|
||||
01 00 00 00 → No auth required
|
||||
02 00 00 00 → MD5 challenge-response (RouterOS < 6.43)
|
||||
03 00 00 00 → EC-SRP5 (RouterOS >= 6.43)
|
||||
```
|
||||
|
||||
### EC-SRP5 Handshake (4 messages after `03 00 00 00`)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant S as Server
|
||||
|
||||
Note over S: Server sent 03 00 00 00
|
||||
|
||||
C->>S: MSG1: [len][username\0][client_pubkey:32][parity:1]
|
||||
Note over C: len = 1 byte, total = len + 1 bytes
|
||||
|
||||
S->>C: MSG2: [len][server_pubkey:32][parity:1][salt:16]
|
||||
Note over S: len = 49 (0x31)
|
||||
|
||||
C->>S: MSG3: [len][client_confirmation:32]
|
||||
Note over C: len = 32 (0x20)
|
||||
|
||||
S->>C: MSG4: [len][server_confirmation:32]
|
||||
Note over S: len = 32 (0x20)
|
||||
|
||||
Note over S: Then continues with normal btest flow:
|
||||
S->>C: AUTH_OK [01 00 00 00]
|
||||
S->>C: UDP port [2 bytes BE] (if UDP mode)
|
||||
```
|
||||
|
||||
### Framing Comparison
|
||||
|
||||
| Protocol | Message framing |
|
||||
|----------|----------------|
|
||||
| Winbox (port 8291) | `[len:1][0x06][payload]` |
|
||||
| **btest (port 2000)** | **`[len:1][payload]`** |
|
||||
| MAC Telnet (UDP 20561) | Control packets with magic bytes |
|
||||
|
||||
The `0x06` handler byte in Winbox identifies the message as an auth message. Btest omits it since the auth context is implicit after `03 00 00 00`.
|
||||
|
||||
### Captured Exchange (from MITM)
|
||||
|
||||
```
|
||||
CLIENT → SERVER (40 bytes):
|
||||
27 61 6e 74 61 72 00 38 8a 37 36 52 6a 32 e9 87 'antar.8.76Rj2..
|
||||
4e 92 f8 c3 aa a1 18 da cd 71 b6 ab 76 fd 72 aa N........q..v.r.
|
||||
c3 f6 6a 43 9b c8 a1 01 ..jC....
|
||||
|
||||
Decoded:
|
||||
len=0x27 (39 bytes payload)
|
||||
username="antar\0"
|
||||
pubkey=388a373652...c8a1 (32 bytes)
|
||||
parity=0x01
|
||||
|
||||
SERVER → CLIENT (50 bytes):
|
||||
31 6c c9 e3 1a 79 43 4a 40 51 de fd 55 cc 8d 6d 1l...yCJ@Q..U..m
|
||||
3c ec cd 73 19 1f a6 83 15 94 62 52 97 fe 5d 89 <..s......bR..].
|
||||
1a 00 3c ec 65 b8 34 28 0a 16 c5 48 0d 7b 50 00 ..<.e.4(...H.{P.
|
||||
e3 80 ..
|
||||
|
||||
Decoded:
|
||||
len=0x31 (49 bytes payload)
|
||||
server_pubkey=6cc9e31a...5d891a (32 bytes)
|
||||
parity=0x00
|
||||
salt=3cec65b834280a16c5480d7b5000e380 (16 bytes)
|
||||
|
||||
CLIENT → SERVER (33 bytes):
|
||||
20 9b 1f 74 ec 40 31 2c ...
|
||||
|
||||
Decoded:
|
||||
len=0x20 (32 bytes payload)
|
||||
client_cc=9b1f74ec... (32 bytes, SHA256 proof)
|
||||
|
||||
SERVER → CLIENT (33 bytes):
|
||||
20 7d 59 b3 2e 28 6e 52 ...
|
||||
|
||||
Decoded:
|
||||
len=0x20 (32 bytes payload)
|
||||
server_cc=7d59b32e... (32 bytes, SHA256 proof)
|
||||
|
||||
POST-AUTH:
|
||||
01 00 00 00 07 fa
|
||||
|
||||
Decoded:
|
||||
AUTH_OK=01000000
|
||||
UDP_port=0x07fa (2042)
|
||||
```
|
||||
|
||||
## Cryptographic Details
|
||||
|
||||
### Elliptic Curve: Curve25519 in Weierstrass Form
|
||||
|
||||
```
|
||||
p = 2^255 - 19
|
||||
r = curve order (same as Ed25519)
|
||||
Montgomery A = 486662
|
||||
|
||||
Weierstrass conversion:
|
||||
a = 0x2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa984914a144
|
||||
b = 0x7b425ed097b425ed097b425ed097b425ed097b425ed097b4260b5e9c7710c864
|
||||
|
||||
Generator: lift_x(9) in Montgomery, converted to Weierstrass
|
||||
Cofactor: 8
|
||||
```
|
||||
|
||||
All EC math is in Weierstrass form. Public keys are transmitted as Montgomery x-coordinates (32 bytes big-endian) plus a 1-byte y-parity flag.
|
||||
|
||||
### Key Derivation
|
||||
|
||||
```
|
||||
inner = SHA256(username + ":" + password)
|
||||
validator_priv (i) = SHA256(salt || inner)
|
||||
validator_pub (x_gamma) = i * G
|
||||
```
|
||||
|
||||
### Shared Secret Computation
|
||||
|
||||
**Client side (ECPESVDP-SRP-A):**
|
||||
```
|
||||
v = redp1(x_gamma, parity=1) # hash-to-curve of validator pubkey
|
||||
w_b = lift_x(server_pubkey) + v # undo verifier blinding
|
||||
j = SHA256(client_pubkey || server_pubkey)
|
||||
scalar = (i * j + s_a) mod r # combined scalar
|
||||
Z = scalar * w_b # shared secret point
|
||||
z = to_montgomery(Z).x # Montgomery x-coordinate
|
||||
```
|
||||
|
||||
**Server side (ECPESVDP-SRP-B):**
|
||||
```
|
||||
gamma = redp1(x_gamma, parity=0)
|
||||
w_a = lift_x(client_pubkey)
|
||||
Z = s_b * (w_a + j * gamma) # where j = SHA256(x_w_a || x_w_b)
|
||||
z = to_montgomery(Z).x
|
||||
```
|
||||
|
||||
### Confirmation Codes
|
||||
|
||||
```
|
||||
client_cc = SHA256(j || z)
|
||||
server_cc = SHA256(j || client_cc || z)
|
||||
```
|
||||
|
||||
Both sides verify the peer's confirmation code to ensure the shared secret matches.
|
||||
|
||||
### redp1 (Hash-to-Curve)
|
||||
|
||||
```python
|
||||
def redp1(x_bytes, parity):
|
||||
x = SHA256(x_bytes)
|
||||
while True:
|
||||
x2 = SHA256(x)
|
||||
point = lift_x(int(x2), parity)
|
||||
if point is valid:
|
||||
return point
|
||||
x = (int(x) + 1).to_bytes(32)
|
||||
```
|
||||
|
||||
## Implementation Plan for Rust
|
||||
|
||||
### Required Crates
|
||||
|
||||
| Crate | Purpose |
|
||||
|-------|---------|
|
||||
| `num-bigint` + `num-traits` | Big integer arithmetic for field operations |
|
||||
| `sha2` | SHA-256 |
|
||||
| `ecdsa` or custom | Curve25519 Weierstrass point operations |
|
||||
|
||||
**Note:** `curve25519-dalek` operates in Montgomery/Edwards form, not Weierstrass. We need Weierstrass arithmetic for compatibility with MikroTik's implementation. Options:
|
||||
1. Use `num-bigint` for direct field arithmetic (like the Python `ecdsa` library)
|
||||
2. Use the `p256` crate's infrastructure with custom curve parameters
|
||||
3. Port the Python `WCurve` class directly using big integers
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. **Port `WCurve`** — Weierstrass curve with Curve25519 parameters, point multiplication, `lift_x`, `redp1`, Montgomery conversion
|
||||
2. **Port EC-SRP5 client** — generate keypair, compute shared secret, confirmation codes
|
||||
3. **Port EC-SRP5 server** — verify client proof, generate server proof (for our server mode)
|
||||
4. **Integrate into `auth.rs`** — handle `03 00 00 00` response with btest-specific `[len][payload]` framing
|
||||
5. **Server registration** — derive salt + validator from username/password for server-side verification
|
||||
|
||||
### Server-Side Specifics
|
||||
|
||||
When our server receives a client with EC-SRP5 support, we need to:
|
||||
1. Store `salt` and `x_gamma` (validator public key) per user — derived from username + password at startup
|
||||
2. Generate ephemeral server keypair
|
||||
3. Compute password-entangled public key: `W_b = s_b * G + redp1(x_gamma, 0)`
|
||||
4. Verify client's confirmation code
|
||||
5. Send server confirmation code
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `proto-test/elliptic_curves.py` | Curve25519 Weierstrass implementation |
|
||||
| `proto-test/btest_ecsrp5_client.py` | Working Python btest EC-SRP5 client |
|
||||
| `proto-test/btest_mitm.py` | MITM proxy for protocol analysis |
|
||||
|
||||
## Credits
|
||||
|
||||
- **[MarginResearch](https://github.com/MarginResearch/mikrotik_authentication)** — Reverse-engineered MikroTik's EC-SRP5 for Winbox/MAC Telnet
|
||||
- **[Margin Research blog](https://margin.re/2022/02/mikrotik-authentication-revealed/)** — Detailed write-up of MikroTik authentication
|
||||
- **btest framing discovery** — MITM analysis showing btest uses `[len][payload]` (no `0x06` handler byte)
|
||||
235
proto-test/btest_ecsrp5_client.py
Normal file
235
proto-test/btest_ecsrp5_client.py
Normal 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
188
proto-test/btest_mitm.py
Normal 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()
|
||||
138
proto-test/elliptic_curves.py
Normal file
138
proto-test/elliptic_curves.py
Normal 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
|
||||
Reference in New Issue
Block a user