mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-31 12:05:05 +02:00
dwt: static analysis of rdebug / DWT console -- poke+jump exist, gated by a config word and a 16-byte handshake
This commit is contained in:
1 parent
be32e8a138
commit
68a1aeabdc
4 files changed
+694
No files matched your search
@@ -0,0 +1,86 @@
|
||||
# DWT / rdebug investigation — cross-agent notes
|
||||
|
||||
Two agents run in parallel:
|
||||
|
||||
| agent | owns | file |
|
||||
|---|---|---|
|
||||
| static (Ghidra) | `rdebug_*` functions, command dispatcher, gates | `dwt/notes/rdebug-static.md` |
|
||||
| protocol (MOP) | MOP Remote Console framing + `dwt/mop_console.py` | `dwt/notes/mop-protocol.md` |
|
||||
|
||||
**Append only, with a timestamp header.** Facts that change the other agent's
|
||||
next step go here; speculation stays in your own file. Do not rewrite another
|
||||
agent's section.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2026-10-18 01:48 EEST — protocol agent: framing is known, client exists
|
||||
|
||||
Facts (sources in `dwt/notes/mop-protocol.md`):
|
||||
|
||||
* Ethernet II, **ethertype 0x6002** = MOP Remote Console (0x6001 = Dump/Load).
|
||||
Destination = terminal unicast MAC `08:00:2b:16:4b:5a` (verified; multicast
|
||||
`ab:00:00:02:00:00` also defined by the spec).
|
||||
* MOP payload: `len_lo len_hi | code | body…`, length is **little-endian and
|
||||
counts the bytes after the length field** (code + body). Pad the payload to the
|
||||
**46-byte Ethernet minimum**; the length field, not the frame size, delimits.
|
||||
* Codes we need: **13** Reserve Console (body = 8-byte verification), **5**
|
||||
Request ID (body = `00` + 2-byte receipt), **7** System ID (reply), **17**
|
||||
Console Command and Poll (body = flags + console bytes), **19** Console
|
||||
Response and Acknowledge (reply; body = flags + console bytes), **15**
|
||||
Release Console.
|
||||
* Session: send 13 then 5; wait for 7. Function bitmap in System ID info TLV
|
||||
type 2, **bit 5 = console carrier** (0x20). Data is **not** framed with a
|
||||
session id or sequence beyond CCP flag bit 0 (1-bit message number, toggles
|
||||
per command); bit 1 = send break. Flag bit 0 of CRA acknowledges the command.
|
||||
* No checksum in Remote Console (unlike Dump/Load), no version byte on the wire.
|
||||
* Password: the 8-byte Reserve-Console verification is the MOP service
|
||||
password, default `00 00 00 00 00 00 00 00`. Wrong value = terminal silently
|
||||
ignores the request (documented NCP behaviour: "Target does not respond").
|
||||
NCL byte order = leftmost hex pair first; NCP integer order = reversed.
|
||||
VT1200 has no documented maintenance password, so start with zeros.
|
||||
* Console data is a raw byte stream. To type a command send `HELP\r`; the
|
||||
terminal echoes `DWT>>> ` and CRLF text. Use `--raw HEX` for arbitrary bytes.
|
||||
|
||||
Client: `dwt/mop_console.py`. `--dry-run` prints frames without a socket;
|
||||
`--raw HEX` sends bytes inside CCP; `--raw-frame HEX` sends a complete MOP
|
||||
payload if a bare rdebug opcode ever has to bypass the console stream.
|
||||
|
||||
## 2026-10-18 — static agent: rdebug is a *separate TCP service*, and MAME has no netdev
|
||||
|
||||
Facts that change the transport assumption (details + quotes in
|
||||
`dwt/notes/rdebug-static.md`):
|
||||
|
||||
1. **The `rdebug_*` family is not reached over MOP.** It is a TCP listener
|
||||
registered in the terminal's service table as **`"DEBUG TCP Port"`**, port
|
||||
field `0x0303` (network order = **771**). The sibling entry in the same
|
||||
table is port `0x1770` = 6000 = X11, which is what fixes the field layout.
|
||||
Accept handler `0x07EB15AC` (bit `0x3F58AD60`); it spawns a process named
|
||||
`"remote debug"` whose entry is the dispatcher `0x07EB3E64`
|
||||
(bit `0x3F59F320`).
|
||||
2. **Wire format is binary, not text.** Request = `u32 be opcode` + opcode
|
||||
arguments (each word big-endian). Response framing =
|
||||
`u32 be (payload_len+1)` + `u8 flag` + payload. Opcode 7 is a no-op that
|
||||
answers `00 00 00 07 00 "Done\n"` — the cheapest liveness test.
|
||||
3. **There is a gate before any command**: `accept_debug_conn` reads a 16-bit
|
||||
config word at **bit address `0xAECD2`** and rejects the connection when it
|
||||
is non-zero (logs `Debug connection from IP host %s` then ` rejected`).
|
||||
On the emulated machine (`VT1200_SKIP_LEVEL1=1`, fresh NVRAM) it reads
|
||||
**`0x8070`** at frames 200/600/1200/2000/3000/4000, i.e. **closed**. Setting
|
||||
`space:write_u16(0xAECD2, 0)` from a debugger script opens it.
|
||||
4. **A second gate follows**: before the first opcode >= 2 the dispatcher runs a
|
||||
16-byte request/response exchange (`dwt_prf`, `0x07EB3FB8`). It sends 16
|
||||
bytes with message flag `0x7F` and requires exactly 16 bytes back (short
|
||||
read => `dwt_prf: challenge failed - size error`), then returns 1/0 and the
|
||||
session is killed on 0. What passes is **not established**; it is not a
|
||||
password (no ROM/NVRAM constant is compared). Echoing the 16 bytes back is
|
||||
the first thing to try.
|
||||
5. **MAME cannot accept the connection today.** `harness/mame/vt1200.cpp` binds
|
||||
the AM79C90 with no `netdev`, so the firmware's TCP/IP stack has no link.
|
||||
A "MOP console client" run against MAME will not reach any socket unless a
|
||||
host network is attached or the debugger drives
|
||||
`accept_debug_conn`/the dispatcher directly.
|
||||
6. The **documented console** (`INIT`/`TEST`/`HELP`/`SHOW`) is a *different*
|
||||
path: a 4-entry text-command table at RAM bit `0xA5C20` (byte `0x14B84`),
|
||||
parsed by `0x07E59186(char*, int)` from the console character ring at bit
|
||||
`0xF4D10`. There are exactly four names; no undocumented fifth entry.
|
||||
@@ -0,0 +1,522 @@
|
||||
# `rdebug_*` and the DWT remote-console path — static analysis
|
||||
|
||||
Ghidra session `d85b60d5e068489f8c43db59d69d907d`, program `vt1200_v22.bin`
|
||||
(`tms34010:LE:32:default`). Address convention used throughout:
|
||||
|
||||
* **Ghidra address = `0x07E40000` + ROM file offset** (the `rom` block starts at
|
||||
`0x07E40000`).
|
||||
* **Firmware bit address = Ghidra address × 8.** Call targets, pointers and
|
||||
table entries in this image are *bit* addresses, so a stored pointer
|
||||
`0x3F5A12E0` means Ghidra `0x07EB425C`, i.e. the entry at that address.
|
||||
|
||||
Everything below that is *not* marked "runtime" comes from the listing /
|
||||
raw bytes. Ghidra's decompiler reports `bad instruction data` over most of this
|
||||
region and mis-decoded the challenge prologue by two bytes; where it mattered I
|
||||
re-decoded the raw bytes by hand and cross-checked with
|
||||
`reference/dis34-a-v22.txt.gz`. The decompiler output was used only for
|
||||
orientation, never as evidence.
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
**Present, and architecturally a payload loader — but gated three times on this
|
||||
image, and the third gate is not yet fully resolved.**
|
||||
|
||||
1. The service is a **TCP listener named `"DEBUG TCP Port"`** (descriptor port
|
||||
field `0x0303` = 771; sibling descriptor in the same table is port `0x1770` =
|
||||
6000 = X11, which confirms the field layout). Accept handler
|
||||
`0x07EB15AC`.
|
||||
2. `accept_debug_conn` logs `Debug connection from IP host %s`, then consults a
|
||||
**16-bit configuration word at bit `0xAECD2`**. If it is **non-zero the
|
||||
connection is rejected** (`" rejected"`, return `-1`) before any protocol
|
||||
byte is exchanged. On the emulated machine
|
||||
(`VT1200_SKIP_LEVEL1=1`, fresh NVRAM) that word reads **`0x8070`** at every
|
||||
frame from 200 to 4000 — i.e. **the gate is closed in this configuration**.
|
||||
3. If the gate were open, the accepted socket becomes a process named
|
||||
`"remote debug"` whose entry is the dispatcher `0x07EB3E64`. Before the
|
||||
first opcode ≥ 2 the dispatcher runs a **16-byte request/response handshake**
|
||||
(`dwt_prf`, `0x07EB3FB8`); if it returns 0 the process exits. What exactly
|
||||
makes that handshake succeed is **not established** (see §6).
|
||||
|
||||
So: *not reachable on a default emulated boot*, and the rdebug opcode set
|
||||
(including `poke` and `jump`) is only reachable after (a) the config word is
|
||||
zero **and** (b) the handshake is satisfied.
|
||||
|
||||
**Confidence:** high that the code path exists, its addresses and wire format
|
||||
are as stated (they were confirmed against a live RAM dump of the dispatch
|
||||
table); medium on the accept gate's polarity/meaning (derived from the listing,
|
||||
and the observed value); **low/unresolved** on the handshake's success
|
||||
condition.
|
||||
|
||||
---
|
||||
|
||||
## 1. Function table
|
||||
|
||||
| role | Ghidra | bit address | found from |
|
||||
|---|---|---|---|
|
||||
| `accept_debug_conn` (TCP accept + gate) | `0x07EB15AC` | `0x3F58AD60` | service descriptor, ROM `0x1A64A4` |
|
||||
| service setup twin (X11 accept) | `0x07EB1706` | `0x3F58B830` | descriptor ROM `0x1A6470` |
|
||||
| shared setup helper | `0x07EB1B42` | `0x3F58DA10` | descriptors `0x1A646C`, `0x1A64A0` |
|
||||
| **dispatcher / session loop** | `0x07EB3E64` | `0x3F59F320` | process entry passed to `create_process` |
|
||||
| **challenge / `dwt_prf`** | `0x07EB3FB8` | `0x3F59FDC0` | `CALLA 0x07EB3FB8` at `0x07EB3F62` |
|
||||
| `write_message` (framing + send) | `0x07EB4164` | `0x3F5A0B20` | every handler |
|
||||
| opcode 0 — pager/`--More--` runner | `0x07EB4216` | `0x3F5A10B0` | table[0] |
|
||||
| opcode 1 — dump_printfs (off) | `0x07EB44D4` | `0x3F5A26A0` | table[1] |
|
||||
| **opcode 2 — `rdebug_peek`** | `0x07EB425C` | `0x3F5A12E0` | table[2] |
|
||||
| **opcode 3 — `rdebug_poke`** | `0x07EB436A` | `0x3F5A1B50` | table[3] |
|
||||
| **opcode 4 — `rdebug_jump`** | `0x07EB4712` | `0x3F5A3890` | table[4] |
|
||||
| opcode 5 — dump_printfs (on) | `0x07EB44B0` | `0x3F5A2580` | table[5] |
|
||||
| **opcode 6 — `rdebug_start_telnet`** | `0x07EB4E02` | `0x3F5A7010` | table[6] |
|
||||
| opcode 7 — no-op (`"Done\n"`) | `0x07EB3F8C` | `0x3F59FC60` | table[7] |
|
||||
| opcode 8 — `rdebug_display_message` | `0x07EB4EAE` | `0x3F5A7570` | table[8] |
|
||||
| opcode 9 — `rdebug_gather_usage_stats` | `0x07EB5042` | `0x3F5A8210` | table[9] |
|
||||
| `ntohl` helper | `0x07F65828` | `0x3FB2C140` | called on every received word |
|
||||
| `memcpy` via `PIXBLT` | `0x07F68880` | `0x3FB44400` | peek/poke copy |
|
||||
| socket read wrapper | `0x07EBB1CE` | `0x3F5D8E70` | every handler |
|
||||
|
||||
### Caller chain
|
||||
|
||||
```
|
||||
"DEBUG TCP Port" service descriptor (ROM 0x1A6490)
|
||||
name = 0x07EB21A2 "DEBUG TCP Port"
|
||||
port = 0x0303 (network order) [sibling descriptor: 0x1770 = 6000/X11]
|
||||
setup = 0x07EB1B42
|
||||
accept= 0x07EB15AC
|
||||
|
|
||||
v
|
||||
accept_debug_conn 0x07EB15AC
|
||||
getsock/accept -> printf("Debug connection from IP host %s", peer)
|
||||
if (*(u16 *)0xAECD2 != 0) { print " rejected"; return -1; }
|
||||
create_process(name="remote debug", entry=0x07EB3E64, stack=0x3000)
|
||||
|
|
||||
v
|
||||
dispatcher 0x07EB3E64
|
||||
loop { recv 4 bytes; op = ntohl(); if (op >= 2 && !challenged) { if (!dwt_prf()) exit(); }
|
||||
call table[op] } table at RAM bit 0x1BB830 (byte 0x37706)
|
||||
```
|
||||
|
||||
Evidence, `listing_disassemble_function` at `0x07EB3E64` (abridged to the
|
||||
dispatch):
|
||||
|
||||
```
|
||||
rom:07eb3eba CALLA 0x07eb0898 ; open/attach session -> *A13(0x40)
|
||||
rom:07eb3ed8 ... loop head ...
|
||||
rom:07eb3ee8 CALLA 0x07ebb1ce ; recv(session, A13, 4, 0)
|
||||
rom:07eb3efc MOVI 0x3f5b66a8,A8 ; (error text for bad opcode)
|
||||
rom:07eb3f02 CALLA 0x07f65828 ; ntohl
|
||||
rom:07eb3f08 MOVE A8,*A13,0x1 ; A13[0] = opcode
|
||||
rom:07eb3f0e SUBK 0xa,A8 ; opcode - 10
|
||||
rom:07eb3f10 JRLS 0x07eb3f50 ; <= 10 (unsigned) -> challenge/dispatch
|
||||
rom:07eb3f50 MOVE *A13,A9,0x1
|
||||
rom:07eb3f52 SUBK 0x2,A9
|
||||
rom:07eb3f54 JRLT 0x07eb3f76 ; opcode < 2 -> dispatch, no challenge
|
||||
rom:07eb3f56 MOVE *A13(0x60),A9,0x1
|
||||
rom:07eb3f5a JRNE 0x07eb3f76 ; already challenged -> dispatch
|
||||
rom:07eb3f62 CALLA 0x07eb3fb8 ; dwt_prf challenge
|
||||
rom:07eb3f6e JRNE 0x07eb3f76 ; non-zero -> dispatch
|
||||
rom:07eb3f70 CALLA 0x07ee0f46 ; zero -> kill the session
|
||||
rom:07eb3f76 MOVE *A13(0x40),*A14+,0x1 ; arg0 = session
|
||||
rom:07eb3f7c MOVE *A13,A9,0x1
|
||||
rom:07eb3f7e SLL 0x5,A9 ; opcode * 32 bits = opcode * 4 bytes
|
||||
rom:07eb3f80 ADDI 0x1bb830,A9
|
||||
rom:07eb3f86 MOVE *A9,A9,0x1
|
||||
rom:07eb3f88 CALL A9
|
||||
rom:07eb3f8a JR 0x07eb3ed8 ; loop
|
||||
```
|
||||
|
||||
Runtime confirmation (`dwt/probe_ram.lua`, `VT1200_SKIP_LEVEL1=1`, frame 2600,
|
||||
reading RAM through MAME's debugger):
|
||||
|
||||
```
|
||||
--- rdebug command table @ bit 0x1bb830 bit=0x001BB830 byte=0x037706
|
||||
[ 0] = 0x3F5A10B0 [ 1] = 0x3F5A26A0 [ 2] = 0x3F5A12E0 [ 3] = 0x3F5A1B50
|
||||
[ 4] = 0x3F5A3890 [ 5] = 0x3F5A2580 [ 6] = 0x3F5A7010 [ 7] = 0x3F59FC60
|
||||
[ 8] = 0x3F5A7570 [ 9] = 0x3F5A8210 [10] = 0x00000001 [11] = 0x3F5AE800
|
||||
```
|
||||
|
||||
`[0]..[9]` are exactly the ten pointers in the ROM initialiser at
|
||||
`0x1A6678`, in the same order. The table is **10 entries**, but the dispatcher
|
||||
accepts `opcode <= 10`, so **opcode 10 indexes one entry past the table and
|
||||
calls `0x00000001`** (bit address 1 = byte 0). That is an out-of-bounds call,
|
||||
not a command; whether byte 0 is executable depends on the RAM-init image, so
|
||||
treat it as a crash, not a primitive. Worth noting as a defect.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wire format (as far as it can be reconstructed)
|
||||
|
||||
### Requests (host → terminal)
|
||||
|
||||
Unframed, big-endian:
|
||||
|
||||
```
|
||||
u32 opcode # ntohl()'d at 0x07EB3F02
|
||||
<opcode-specific arguments> # each 32-bit field is ntohl()'d by the handler
|
||||
```
|
||||
|
||||
The dispatcher reads exactly 4 bytes (`MOVK 0x8`→ push 8? no: `MOVK 0x8` then
|
||||
`MOVE A13` … see §3 for the exact call shape) and byte-swaps them. The handlers
|
||||
then read their own fixed-size headers raw and `ntohl` each word. There is no
|
||||
length prefix on requests and no request checksum.
|
||||
|
||||
### Responses (terminal → host)
|
||||
|
||||
Every response is framed by `write_message` `0x07EB4164`:
|
||||
|
||||
```
|
||||
u32 be length = payload_len + 1
|
||||
u8 flag
|
||||
u8[] payload (payload_len bytes)
|
||||
```
|
||||
|
||||
`write_message(session, flag, buf, len)`; the argument order is fixed by its
|
||||
prologue, which reads `*A13(0xffc0)` as a **byte** into `*A13(0x20)` (offset
|
||||
+4), `*A13(0xff80)` as the length (clamped to 0x400), and `*A13(0xffa0)` as the
|
||||
payload pointer:
|
||||
|
||||
```
|
||||
rom:07eb416e MOVB *A13(0xffc0),*A13(0x20) ; flag byte at buf+4
|
||||
rom:07eb4178 CMPI 0x400,A11 ; length clamp
|
||||
rom:07eb4196 MOVE A8,*A13,0x1 ; ntohl(len+1) at buf+0
|
||||
rom:07eb41aa CALLA 0x07f68880 ; memcpy(buf+5, payload, len)
|
||||
rom:07eb41b6 ADDK 0x4,A11
|
||||
rom:07eb41c0 CALLA 0x07eba95e ; send(session, buf, len+5)
|
||||
```
|
||||
|
||||
Flags observed in the handlers: `0` = text (`"Done\n"`), `1` = binary data,
|
||||
`0x7F` = the challenge frame. `"Done\n"` is the literal 6-byte string at
|
||||
`0x07EB6CE9` and terminates every successful command.
|
||||
|
||||
### Per-opcode arguments (from the listings)
|
||||
|
||||
| op | request after opcode | response |
|
||||
|---|---|---|
|
||||
| 0 | *(none read; runs pager)* | text/`"Done\n"`, then **exits** |
|
||||
| 1 | *(none)* | `"Done\n"` |
|
||||
| 2 peek | `u32 addr; u32 len` (both be) | `len` raw bytes (flag 1) + `"Done\n"` |
|
||||
| 3 poke | `u32 addr; u32 len` then `len` raw bytes | `"Done\n"` |
|
||||
| 4 jump | `u32 addr; u32 nargs` then `nargs`×`u32` | `u32 be retval` (flag 1) + `"Done\n"` |
|
||||
| 5 | *(none)* | stream of captured console text |
|
||||
| 6 telnet | up to 255 bytes hostname | `"Done\n"` |
|
||||
| 7 | *(none)* | `"Done\n"` |
|
||||
| 8 display | `u32 ?, u32 len` then `len` bytes text | `"Done\n"` |
|
||||
| 9 stats | 28 bytes (`0x1C`) request block | stats block + `"Done\n"` |
|
||||
|
||||
---
|
||||
|
||||
## 3. `rdebug_peek` — arbitrary read
|
||||
|
||||
`0x07EB425C`:
|
||||
|
||||
```
|
||||
rom:07eb4266 MOVE A14,-*SP,0x1 ; SUB A11,A11 ; MOVK 0x8,A11 ; MOVE A13
|
||||
rom:07eb4276 CALLA 0x07ebb1ce ; recv(session, A13, 8, 0)
|
||||
rom:07eb4282 JRGE 0x07eb42ae ; <0 -> "rdebug_peek: recv error %d"
|
||||
rom:07eb42b4 CALLA 0x07f65828 ; ntohl(*A13(0x20)) = length
|
||||
rom:07eb42c4 CALLA 0x07f65828 ; ntohl(*A13(0x00)) = address
|
||||
rom:07eb42cc MOVE *A13(0x20),A11,0x1
|
||||
rom:07eb42d0 CMPI 0x400,A11
|
||||
rom:07eb42d4 JRLS 0x07eb431a ; length <= 1024 -> copy
|
||||
rom:07eb42d8 MOVI 0x400,A9
|
||||
rom:07eb42de MOVI 0x3f5b6a50,A8 ; "rdebug_peek: maximum peek size is %d bytes"
|
||||
rom:07eb430e CALLA 0x07eb4164 ; send error text
|
||||
rom:07eb4314 CALLA 0x07ee0f46 ; and exit the process
|
||||
rom:07eb431a ... memcpy(buf, addr, len) ...
|
||||
rom:07eb433e CALLA 0x07eb4164 ; send(buf, len, flag=1)
|
||||
rom:07eb435a CALLA 0x07eb4164 ; send("Done\n", 6, flag=0)
|
||||
```
|
||||
|
||||
The address is taken verbatim from the request and used as a **bit address**;
|
||||
there is **no validation at all** — not even "is this in RAM". Any address the
|
||||
CPU can read is readable: ROM, I/O registers, VRAM, NVRAM. Length is capped at
|
||||
0x400 (1024) per request, but the command can be repeated.
|
||||
|
||||
## 4. `rdebug_poke` — arbitrary write
|
||||
|
||||
`0x07EB436A`:
|
||||
|
||||
```
|
||||
rom:07eb4374 ... recv(session, A13, 8, 0) ; {addr, len}
|
||||
rom:07eb4390 JRGE 0x07eb43bc
|
||||
rom:07eb43c2 CALLA 0x07f65828 ; ntohl(addr)
|
||||
rom:07eb43d0 CALLA 0x07f65828 ; ntohl(len)
|
||||
rom:07eb43da CMPI 0x400,A8
|
||||
rom:07eb43de JRLS 0x07eb4424 ; len <= 1024
|
||||
rom:07eb43e2 MOVI 0x400,A11
|
||||
rom:07eb43e8 MOVI 0x3f5b6c88,A8 ; "rdebug_poke: maximum poke size is %d bytes"
|
||||
...
|
||||
rom:07eb4424 ... recv(session, A13+0x2310, len, 0) ; payload into a stack buffer
|
||||
rom:07eb4446 JRGE 0x07eb4472
|
||||
rom:07eb445a MOVI 0x3f5b6de0,A8 ; "rdebug_poke: recv error %d (2)"
|
||||
rom:07eb4472 ... memcpy(addr, A13+0x2310, len) ...
|
||||
rom:07eb44a0 CALLA 0x07eb4164 ; send("Done\n")
|
||||
```
|
||||
|
||||
* **Maximum poke size = 0x400 (1024) bytes** per request; repeat as needed.
|
||||
* Destination = the requested **bit address**, unchecked.
|
||||
* ROM is *not* write-protected by the handler: whether a write to the ROM
|
||||
window sticks is a property of the memory map, not of this code. On the
|
||||
TMS34010 the ROM region is mapped read-only in the driver, so ROM writes
|
||||
would fault/discard; **RAM at bit `0x00000000`–`0x001FFFFF` and I/O at
|
||||
`0x0C000000`-range are writable** and reachable by address alone.
|
||||
* The address is a **bit** address: to write byte `B` of main RAM, send
|
||||
`B*8`. A host that sends a byte address writes to the wrong place — this is
|
||||
the single most likely mistake when testing.
|
||||
|
||||
## 5. `rdebug_jump` — arbitrary call
|
||||
|
||||
`0x07EB4712`:
|
||||
|
||||
```
|
||||
rom:07eb471c ... recv(session, A13, 8, 0) ; {addr, nargs}
|
||||
rom:07eb4738 JRGE 0x07eb4764
|
||||
rom:07eb474c MOVI 0x3f5b70f0,A8 ; "rdebug_jump: recv error %d"
|
||||
rom:07eb476a CALLA 0x07f65828 ; ntohl(addr)
|
||||
rom:07eb4778 CALLA 0x07f65828 ; ntohl(nargs)
|
||||
rom:07eb4786 SUBK 0x14,A8 ; nargs - 20
|
||||
rom:07eb4788 JRLE 0x07eb47ac ; <= 20 args accepted
|
||||
rom:07eb4790 MOVI 0x3f5b71d0,A8 ; (too-many-args text)
|
||||
rom:07eb479e CALLA 0x07eb4164 ; JR 0x07eb4df8
|
||||
rom:07eb47ac MOVE A9,A8
|
||||
rom:07eb47ae JRNE 0x07eb47bc ; nargs != 0 -> malloc args
|
||||
rom:07eb47b0 MOVE *A13,A11,0x1
|
||||
rom:07eb47b2 CALL A11 ; nargs == 0: bare call
|
||||
rom:07eb47b4 MOVE A8,*A13(0x60),0x1
|
||||
rom:07eb47bc ... malloc(nargs*4); recv(session, buf, nargs*4, 0) ...
|
||||
rom:07eb4814 JRGE 0x07eb484c
|
||||
rom:07eb4828 MOVI 0x3f5b7298,A8 ; "rdebug_jump: recv error %d args"
|
||||
rom:07eb485e JRGE 0x07eb4d3c ; ntohl each arg
|
||||
rom:07eb4d3c MOVE *A13(0x20),A8,0x1
|
||||
rom:07eb4d40 DEC A8
|
||||
rom:07eb4d42 CMPI 0x13,A8
|
||||
rom:07eb4d46 JRHI 0x07eb4da4
|
||||
rom:07eb4d48 SLL 0x5,A8
|
||||
rom:07eb4d4a ADDI 0x3f5a6aa0,A8 ; jump table of 20 call stubs
|
||||
rom:07eb4d50 MOVE *A8,A8,0x1
|
||||
rom:07eb4d52 JUMP A8
|
||||
```
|
||||
|
||||
So `rdebug_jump` is **`call(bit_address, args...)`**, not a PC assignment:
|
||||
the address is called as a function with 0–20 arguments taken from the wire.
|
||||
The 20 stubs push 1..20 words and then `CALL *A13(0)`. The return value is
|
||||
`ntohl`'d and sent back as a 4-byte message (flag 1), followed by `"Done\n"`.
|
||||
|
||||
There is no "args" structure and no argument type checking; each argument is a
|
||||
raw 32-bit word. This is the payload loader: `poke` the code into RAM, then
|
||||
`jump` to it (or `jump` to an existing ROM routine with chosen arguments).
|
||||
|
||||
Because `jump` never returns to the dispatcher on a non-returning target,
|
||||
a payload can take over the `"remote debug"` process cleanly.
|
||||
|
||||
## 6. The `dwt_prf` challenge — what is actually checked
|
||||
|
||||
`0x07EB3FB8`, called once per session before the first opcode ≥ 2. Hand-decoded
|
||||
from the raw bytes (Ghidra's decode is off by two bytes at `0x3FC8`):
|
||||
|
||||
```
|
||||
3fc2 CALLA 0x07eb31a0 ; ticks() = REFCNT + global
|
||||
3fc8 MOVE A8,*A13(0x100) ; +32 bytes
|
||||
3fcc CALLA 0x07f67f38 ; *0x1E93C0 (clock high word / counter)
|
||||
3fd2 MOVE A8,*A13(0x120) ; +36
|
||||
3fd6 CALLA 0x07eb905c ; *0x1DA830 (another global)
|
||||
3fdc MOVE A8,*A13(0x140) ; +40
|
||||
3fe0 CALLA 0x07eb31a0 ; ticks() again
|
||||
3fe6 MOVE A8,*A13(0x160) ; +44
|
||||
3fea..4024 ntohl() each of the four words in place
|
||||
4028 push 0x10 ; push A13 ; push 0x7F ; push session
|
||||
403a CALLA 0x07eb4164 ; write_message(flag=0x7F, A13, 16)
|
||||
4040 push 0 ; push 0x10 ; push A13+0x80 ; push session
|
||||
4056 CALLA 0x07ebb1ce ; recv(session, frame+16, 16, 0)
|
||||
4062 JRGE 0x07eb4082
|
||||
4066..4078 printf("dwt_prf: challenge failed - size error (expecting %d, got %d)", 16, ret)
|
||||
4082..40be ntohl() the four received words in place
|
||||
40c2 CALLA 0x07f6670e ; disable interrupts, save state
|
||||
40d2 CALLA 0x07eb52c4 (1) ; allocate 0x800 + 0x80 buffers
|
||||
40e2 CALLA 0x07eb5458 (0x1be810); expand 128 bytes of the block at 0x1BE810
|
||||
40f6 CALLA 0x07eb4138 (frame+16, 16) ; process the 16 received bytes (2 x 8-byte records)
|
||||
40fc CALLA 0x07eb53f4 ; free buffers
|
||||
4108 CALLA 0x07f6671a ; restore interrupt state
|
||||
411e CALLA 0x07f28446 ; memcmp(frame+32, A11, 16)
|
||||
4126 JREQ -> return 1 else 0
|
||||
```
|
||||
|
||||
Findings:
|
||||
|
||||
* It is **not a password**: nothing is compared against a ROM constant, NVRAM
|
||||
string, or stored key. There is no MAC/CRC routine here either.
|
||||
* It **is** a length check *plus* a 16-byte content check. A short read is
|
||||
reported with the quoted `"challenge failed - size error (expecting %d, got
|
||||
%d)"` string and the function returns the negative read count (the dispatcher
|
||||
treats non-zero as failure and exits).
|
||||
* The compared bytes are produced **at run time**: the reply is fed through the
|
||||
terminal's own bitmap/plot routines (`0x07EB52C4`, `0x07EB5458`,
|
||||
`0x07EB4138`, `0x07EB5748`) and the result is `memcmp`'d with the 16 bytes
|
||||
the terminal itself sent. `0x07EB5748` treats each 8-byte half of the reply
|
||||
as an `{x, y}`-style plotting record; `0x1BE810` is a font/bitmap block, not
|
||||
a secret (runtime dump shows `55 F8 61 C7 E5 13 11 D4 C8 00 …`).
|
||||
* Because the "expected" value is derived from local state and the reply, a
|
||||
reply that merely echoes the 16 bytes is **plausible** to pass, but
|
||||
**I did not establish it** — the plot/transform path between the `recv` and
|
||||
the `memcmp` is not fully resolved, and `A11` at the `memcmp` is the
|
||||
internal buffer pointer (`@0x1C0260 + 0x5C0`), not a stored key.
|
||||
|
||||
**What would settle it (single experiment):** run the challenge outside the
|
||||
network. With a MAME debugger script, set `PC = 0x3F59FDC0`, make the session
|
||||
handle in `A13(-0x20)` a connected socketpair (or patch `0x07EBB1CE` to return
|
||||
a canned 16-byte reply from RAM), feed the reply `= the 16 bytes just sent`,
|
||||
and break at `0x07EB4124` to read the `memcmp` result. If `A8 == 1`, the gate
|
||||
is an echo and the whole console is reachable by anyone who can connect.
|
||||
|
||||
## 7. Mode gate
|
||||
|
||||
* There is **no** build-time or jumper gate visible: the service is in the
|
||||
production service table, and the accept function is reachable from it.
|
||||
* The actual gate is the **16-bit configuration word at bit `0xAECD2`**.
|
||||
`accept_debug_conn` reads it with `SETF 2,0,0` and rejects when it is
|
||||
non-zero. It is written by the configuration loader at `0x07E653E0` (and
|
||||
`0x07E653E0`-neighbouring code sets `0xAECD0` the same way) from a config
|
||||
descriptor, so it is a **persistent setting**, not a compile-time constant.
|
||||
* Runtime observation, `VT1200_SKIP_LEVEL1=1`, fresh NVRAM:
|
||||
`@0xAECD0 = 0x8070`, `@0xAECD2 = 0x8070` at frames 200, 600, 1200, 2000,
|
||||
3000, 4000 — stable, i.e. **the debug TCP service refuses connections in this
|
||||
configuration**. I did not determine which user-visible setting drives it,
|
||||
nor the real terminal's factory value.
|
||||
* Independently, the service can only be reached if the terminal's own TCP/IP
|
||||
stack is up and listening — i.e. after a successful boot. The MAME driver
|
||||
currently attaches **no netdev to the LANCE**, so a host cannot connect at
|
||||
all in the emulator (see §10).
|
||||
|
||||
## 8. `rdebug_dump_printfs` — console capture (opcode 5/1)
|
||||
|
||||
`0x07EB44B0` sets `@0x1BB810 = 1` then calls `0x07EB44F8`; `0x07EB44D4` clears
|
||||
it and calls the same function. `0x07EB44F8`:
|
||||
|
||||
```
|
||||
rom:07eb4500 MOVE @0x1bb730,A11 ; already streaming -> error text + exit
|
||||
rom:07eb453c CALLA 0x07ee0246 ; malloc(0x400)
|
||||
rom:07eb4588 MOVK 1,A7 ; MOVE A7,@0x1bb730
|
||||
rom:07eb4590 MOVE 0x1b3900,@0x1bb770 ; install a printf sink struct
|
||||
rom:07eb45a8 CALLA 0x07edf58e
|
||||
rom:07eb45f4 CALLA 0x07eb4164 ; write_message(flag=1, captured text, len)
|
||||
```
|
||||
|
||||
The remote host gets a **stream of the terminal's own printf/console output**.
|
||||
That is an information leak on its own (messages, error text, possibly
|
||||
filenames and user data) and a useful reconnaissance primitive before `poke`.
|
||||
|
||||
## 9. The documented console (INIT / SHOW / TEST / HELP)
|
||||
|
||||
This is a **different dispatcher** from rdebug. It lives on the console input
|
||||
path, not on the debug socket:
|
||||
|
||||
* Parser: `0x07E59186(char *buf, int len)` in Ghidra (`0x3F2C8C30`), called
|
||||
from `0x07F5E646` with the character-ring buffer at bit `0xF4D10`
|
||||
(byte `0x1E9A2`) and length `*(u16 *)0x…(0x70) - 2`.
|
||||
* Command table: **RAM bit `0xA5C20` (byte `0x14B84`)**, 12-byte entries
|
||||
`{ char *name; void (*handler)(); u32 0 }`, four entries. Preceded at
|
||||
`0xA5C00` by a pointer to `"\r\n"`.
|
||||
* Runtime dump (frame 2600) confirms:
|
||||
|
||||
```
|
||||
0xA5C20 "TEST" 0x3FAD3900 = 0x07F5A720
|
||||
0xA5C80 "INIT" 0x3F2C7620 = 0x07E58EC4
|
||||
0xA5CE0 "HELP" 0x3F2C71C0 = 0x07E58E38
|
||||
0xA5D40 "SHOW" 0x3F2C7840 = 0x07E58F08
|
||||
```
|
||||
|
||||
* Dispatcher `0x07E5945C` does `stricmp(entry->name, argv[0])` via
|
||||
`0x07E59526` and returns 2 on match; `0x07E592B8` parses the rest of the line,
|
||||
then `0x07E592DC` loads `entry->handler` (`*(entry+0x20 bits)` = +4 bytes) and
|
||||
calls it.
|
||||
* Strings: `"DWT>>> "` at `0x07E5975A`, banner
|
||||
`"\r\n DECwindows Terminal Remote Console\r\n"` at `0x07E59777`,
|
||||
`" Commands available:"` at `0x07E597A0`, per-command help text from
|
||||
`0x07E597F6`.
|
||||
|
||||
**Complete command-name list (documented path): `TEST`, `INIT`, `HELP`, `SHOW`.**
|
||||
There is no fifth entry — the table is exactly four records, and the
|
||||
`"Commands available:"` help block lists the same four.
|
||||
|
||||
Relationship to rdebug: none. The DWT console takes *text lines* from the
|
||||
console character stream; rdebug takes *binary opcodes* from a TCP socket. They
|
||||
share only the output helper (`0x07E59154`).
|
||||
|
||||
## 10. `rdebug_start_telnet` — outbound dial
|
||||
|
||||
`0x07EB4E02`:
|
||||
|
||||
```
|
||||
rom:07eb4e0c push 0 ; MOVI 0xff,A11 ; push ; push A13 ; push session
|
||||
rom:07eb4e1e CALLA 0x07ebb1ce ; recv(session, A13, 255, 0) -> hostname
|
||||
rom:07eb4e2a JRGE 0x07eb4e56
|
||||
rom:07eb4e3e MOVI 0x3f5b73a0,A8 ; "rdebug_start_telnet: recv error %d"
|
||||
rom:07eb4e56 MOVI 0x3f5b74c0,A8 ; "Starting telnet session to %s by request of remote host."
|
||||
rom:07eb4e6a CALLA 0x07f66886 ; sprintf(buf, fmt, hostname)
|
||||
rom:07eb4e78 CALLA 0x07e6126a ; log it
|
||||
rom:07eb4e7e push A13
|
||||
rom:07eb4e82 CALLA 0x07eda7a0 ; start the terminal's telnet client on that name
|
||||
rom:07eb4e9e CALLA 0x07eb4164 ; write_message("Done\n")
|
||||
```
|
||||
|
||||
The host string is up to 255 bytes and is used **verbatim** as the telnet
|
||||
destination. The remote host therefore controls an **outbound TCP connection
|
||||
from the terminal** — a reverse channel / SSRF, and an exfiltration path
|
||||
(connect to an attacker host and the terminal's telnet client speaks to it).
|
||||
No allow-list and no confirmation dialog were found on this path.
|
||||
|
||||
## 11. `rdebug_display_message` and `gather_usage_stats`
|
||||
|
||||
* `0x07EB4EAE`: reads 8 bytes `{u32 ?, u32 len}`, rejects `len > 0x400`
|
||||
(`"rdebug_display_message: len (%d) > 1024 || < 1"`), `malloc(len+2)`, reads
|
||||
`len` bytes, then calls `0x07E6126A(level, text)` to put the text on the
|
||||
terminal's screen, then `"Done\n"`. Up to 1024 bytes of **attacker-chosen text
|
||||
displayed on the terminal** — social engineering / UI spoofing.
|
||||
* `0x07EB5042`: reads a 28-byte request, copies a 28-byte block from
|
||||
`0x1BE860`, `ntohl`s four fields, checks a duration
|
||||
(`"rdebug_gather_usage_stats: duration < 1"`), calls `malloc`/`calloc`
|
||||
(`"calloc failed"`) and spawns a collector. Returns terminal usage data to
|
||||
the remote host.
|
||||
|
||||
## 12. Honest gaps
|
||||
|
||||
1. **Handshake pass condition** — see §6. Not resolved; the one experiment that
|
||||
resolves it is given there.
|
||||
2. **Accept gate meaning** — `0xAECD2` is a 16-bit config word; I know its
|
||||
polarity (non-zero ⇒ reject) and its emulated value (`0x8070`), but not
|
||||
which user-visible setting writes it or what the real terminal ships with.
|
||||
3. **Service table layout** — the descriptor at `0x1A6490` reads
|
||||
`{name="DEBUG TCP Port", 0x0002, 0x0303, 0, 1, setup, accept}`; the sibling
|
||||
`{…, 0x0002, 0x1770, …}` is X11 on 6000, which is what fixes `0x0303` as a
|
||||
port (771). The exact struct offsets were inferred, not proven.
|
||||
4. **`recv` semantics** — the wrapper `0x07EBB1CE` is a stateful read with
|
||||
timeout handling (`0x07EE11BC`, `0x07EE1268`); I did not trace whether it
|
||||
can return short reads. Each handler treats a negative return as fatal, so a
|
||||
short read is at worst a session abort.
|
||||
5. **Reachability in MAME is currently zero** because the driver binds no
|
||||
netdev to the AM79C90. A host cannot open the TCP connection at all; the
|
||||
firmware's own stack has no link. Any test of this path needs either a
|
||||
driver-side netdev (out of scope for this note) or a debugger-driven call
|
||||
into `accept_debug_conn`/the dispatcher.
|
||||
|
||||
## 13. Shortest path to a test
|
||||
|
||||
1. Boot `VT1200_SKIP_LEVEL1=1` (the OS must be up for the TCP stack).
|
||||
2. At frame ~2500, set the gate word to zero:
|
||||
`space:write_u16(0xAECD2, 0)` (bit address; MAME Lua, debugger enabled).
|
||||
3. Give the LANCE a host network (a `netdev`/`pcap` attachment on the driver is
|
||||
required; without it there is nothing to connect to).
|
||||
4. Connect to TCP port **771** and send `u32 be 7` (`0x00000007`) — opcode 7 is
|
||||
the no-op. A correct reply proves the accept path and framing:
|
||||
`00 00 00 07 00 'D' 'o' 'n' 'e' '\n'`.
|
||||
5. If the handshake fires (`dwt_prf`) and replies with 16 bytes, capture them
|
||||
and try echoing them back; if the session survives, send
|
||||
`u32 be 2` + `u32 be <addr*8>` + `u32 be 4` and check for 4 bytes of ROM
|
||||
(e.g. address `0x07E40000*8 = 0x3F200000` returns the first ROM bytes).
|
||||
6. Only then `poke` (`opcode 3`) and `jump` (`opcode 4`) — see `harness/hello-world`
|
||||
for a payload that is known to run on this CPU.
|
||||
|
||||
---
|
||||
|
||||
*ROM untouched (`md5 b46a3e442ccb86134d2356bd70d22fd2`). Runtime probes used:
|
||||
`dwt/probe_ram.lua`, `dwt/probe_cfg.lua` (read-only memory dumps).*
|
||||
Reference in new issue
Block a user