mirror of
https://git.k-space.ee/arti/vt1200.git
synced 2026-10-31 12:05:05 +02:00
161 lines
7.8 KiB
Markdown
161 lines
7.8 KiB
Markdown
# Attacker-chosen text in the Terminal Manager Messages box (rdebug opcode 8)
|
||
|
||
Status: **DONE — opcode 8 (`rdebug_display_message`) puts attacker-chosen text
|
||
in the Terminal Manager's Messages box.** No payload, no code injection and no
|
||
debugger breakpoint are involved. The visible result is the same pane the OS
|
||
uses for `System defaults loaded.`, `TCP/IP is now up.` and
|
||
`Debug connection from IP host …`.
|
||
|
||
ROM untouched (`md5 b46a3e442ccb86134d2356bd70d22fd2`). No driver edits.
|
||
`VT1200_SKIP_LEVEL1=1` (documented divergence), private `-nvram_directory` and
|
||
BOOTP/TFTP as in the other OS-path runs.
|
||
|
||
## 1. The wire request
|
||
|
||
```
|
||
request = u32be 8 | u32be level | u32be len | len raw bytes
|
||
```
|
||
|
||
`len` counts the raw bytes that follow and must be 1..1024. **The NUL
|
||
terminator is the sender's job.** The handler `malloc(len + 2)` and copies
|
||
`len` bytes, but it never writes a terminator. A message sent without one is
|
||
followed on screen by stale heap bytes (first experiment: the 29-byte
|
||
`RDEBUG MSG ONE - LEVEL ZERO` rendered as `RDEBUG MSG ONE - LEVEL ZEROUUUUUU@`).
|
||
Sending `text + NUL` with `len = len(text)+1` renders cleanly. So the largest
|
||
clean text is 1023 bytes, not 1024.
|
||
|
||
Exact requests used (dry run, `dwt/exploit.py --dry-run`):
|
||
|
||
```
|
||
# level 0, "ATTACKER MESSAGE ONE" + NUL (len 0x15)
|
||
00 00 00 08 00 00 00 00 00 00 00 15
|
||
41 54 54 41 43 4b 45 52 20 4d 45 53 53 41 47 45 20 4f 4e 45 00
|
||
|
||
# level 3, "LEVEL THREE TERTIARY" + NUL (len 0x15)
|
||
00 00 00 08 00 00 00 03 00 00 00 15
|
||
4c 45 56 45 4c 20 54 48 52 45 45 20 54 45 52 54 49 41 52 59 00
|
||
```
|
||
|
||
The reply is the usual `"Done\n"` frame. `dwt/exploit.py` now has
|
||
`RDebug.display_message(level, text, terminate=True)` and a `--message TEXT`
|
||
option; `terminate=True` appends the NUL (and therefore counts it in `len`).
|
||
|
||
## 2. What `level` does
|
||
|
||
`level` is a syslog-style message class, not an offset or a length. The append
|
||
routine is `0x07E6126A` (bit `0x3F309350`) — the same routine `accept_debug_conn`
|
||
calls for `Debug connection from IP host %s`. On entry it checks per-class
|
||
enable bits in RAM:
|
||
|
||
| level | check (bit address) | on this boot | visible in Messages |
|
||
|---|---|---|---|
|
||
| 0 | `@0xAECE2` (byte `0xAECE0` bit 2) | **1** | **yes** |
|
||
| 1 | `@0xAECE3` (bit 3) | 0 | no |
|
||
| 2 | `@0xAECE4` (bit 4) | 0 | no |
|
||
| 3 | no check — always accepted | — | **yes** |
|
||
|
||
Measured byte at `0xAECE0` on the live OS (`opcode 2` peek): **`05`** =
|
||
bit 2 set, bits 3 and 4 clear. That matches the observed result exactly:
|
||
levels 0 and 3 render; 1 and 2 are silently dropped. Levels 0 and 3 write to
|
||
the same Messages list — the level chooses *whether* the line is admitted, not
|
||
*where* it goes.
|
||
|
||
Disassembly of the filter: `3F3093B0` (bit `0xAECE2` / `jreq` exit when the
|
||
class is 0), `3F309430` (`0xAECE3`), `3F3094C0` (`0xAECE4`, `subk 2` /
|
||
`jreq` exit). Level 3 falls through all of them.
|
||
|
||
## 3. Where it appears, and what the pane does
|
||
|
||
* **The Terminal Manager Messages box**, below the `Messages` heading and above
|
||
the Ethernet/IP/LAT block. Nothing else on the screen changes: no dialog, no
|
||
popup, no status line.
|
||
* It is **not modal**. The TM keeps running, the rdebug session stays usable
|
||
(`opcode 7` answers after the messages) and the message is just another line
|
||
in the list.
|
||
* The pane is a **5-line viewport**. The append routine computes the top line
|
||
as `count - 5` (`3F30A940` / `3F30AA40`), which is why sending eight
|
||
`SCROLL LINE 01…08` messages leaves `SCROLL LINE 04…08` visible. Older lines
|
||
go out of view (the OS's own `System defaults loaded.` / `Using local fonts.`
|
||
scroll off the same way); the scrollbar is present. A hard cap on retained
|
||
lines was not established; the only verified per-message bound is the
|
||
1024-byte `len` check.
|
||
* **Persistence:** after the three demo messages the frame at 944 and the frame
|
||
at 1938 (≈1000 frames later) are byte-identical
|
||
(`4bf8e2299d4ee704848a0b107bf2a31d`). The line persists and survives the OS's
|
||
repaints.
|
||
* **Repaint timing (honest caveat):** in the controlled run the text appeared in
|
||
the next snapshots (5–14 frames after the send). In one earlier run the very
|
||
first message was buffered but not drawn for more than 130 frames, until the
|
||
next message/connection caused a pane redraw. So the list update itself is
|
||
immediate; when it becomes visible depends on the TM redrawing the pane.
|
||
|
||
## 4. Why no payload is needed
|
||
|
||
The firmware already ships the primitive. There is no reason to call the
|
||
append routine from a `jump` payload: `dwt/visual/msg.asm` was **not** built,
|
||
because opcode 8 reaches the same routine with an attacker-controlled string and
|
||
returns `Done`. It composes trivially with the visual work — the same rdebug
|
||
connection can `poke`/`jump` a payload and then send opcode 8; no hook, no
|
||
resident code, no breakpoint.
|
||
|
||
## 5. Reproduce
|
||
|
||
The host tap `tap-mess-<uid>-0` is fixed by MAME's taptun provider, so a second
|
||
MAME cannot share it. This run used a private network namespace with its own
|
||
`tap-mess-1000-0`, bridge and veth to the host, while another MAME held the
|
||
default tap. Nothing about the result depends on that choice; on a free tap the
|
||
ordinary harness command works.
|
||
|
||
```sh
|
||
# terminal (private NVRAM; 192.168.100.2 comes from harness/serve.py --bootp)
|
||
VT1200_SKIP_LEVEL1=1 \
|
||
/home/arti/code/k-space/mame0289/vt1200 vt1200 -rompath harness/mame/roms \
|
||
-sound none -skip_gameinfo -networkprovider taptun -video none -nothrottle \
|
||
-cfg_directory <cfg> -nvram_directory <nvram> \
|
||
-debug -debugger none -autoboot_script dwt/visual/msg_snap.lua \
|
||
-autoboot_delay 0
|
||
|
||
# host: one connection, challenge answered by dwt/challenge.respond
|
||
python3 dwt/visual/msg_demo.py --host 192.168.100.2 --wait 40 \
|
||
--message '0:ATTACKER MESSAGE ONE' \
|
||
--message '0:RDEBUG TEXT TWO' \
|
||
--message '3:LEVEL THREE TERTIARY'
|
||
```
|
||
|
||
`msg_snap.lua` is read-only with respect to the emulated machine: it reads the
|
||
frame counter and takes `screen:snapshot()` PNGs when the host writes a trigger
|
||
file (`"<prefix> <count> <stride>"`). No breakpoints, no firmware writes.
|
||
|
||
## 6. Evidence
|
||
|
||
| file | what it shows |
|
||
|---|---|
|
||
| `dwt/evidence/visual/msg_before_frame00824.png` | before: five OS lines, no attacker text |
|
||
| `dwt/evidence/visual/msg_after_frame00944.png` | after: `ATTACKER MESSAGE ONE`, `RDEBUG TEXT TWO`, `LEVEL THREE TERTIARY` in the Messages box |
|
||
| `dwt/evidence/visual/msg_persist_frame01938.png` | ≈1000 frames later: identical frame (`md5 4bf8e229…`) |
|
||
| `dwt/evidence/visual/msg_scroll_frame07979.png` | eight lines sent; viewport shows the last five (`SCROLL LINE 04…08`) |
|
||
| `dwt/evidence/visual/msg_levels_frame15265.png` | level 1 and 2 sent just before: invisible; level 3 visible |
|
||
| `dwt/evidence/visual/msg_*_crop.png` | the Messages box crops of the above |
|
||
| `dwt/evidence/visual/msg_client.log` | challenge, reply and every opcode-8 body on the wire |
|
||
|
||
The log was captured as `msg2_client.log`; only the PNG prefix was renamed
|
||
(`msg2_*` → `msg_*`) when the duplicate run-1 frames were dropped.
|
||
|
||
Also `dwt/visual/msg_demo.py` (new host driver) and `dwt/visual/msg_snap.lua`
|
||
(new snapshot trigger). `dwt/exploit.py` gained only additive support
|
||
(`display_message`, `--message`, `--message-level`); existing behaviour is
|
||
unchanged.
|
||
|
||
## 7. Caveats
|
||
|
||
* The challenge (`dwt_prf`) still has to be answered; `msg_demo.py` uses
|
||
`dwt/challenge.respond`, the established offline answer. This note is about
|
||
what the service does once a session is open, not about bypassing the
|
||
challenge.
|
||
* Level 1/2 are filtered on this boot because their enable bits are clear.
|
||
Writing `0xAECE0` to set bits 3/4 would presumably enable them, but that is a
|
||
runtime config change and was not needed; 0 and 3 are both usable.
|
||
* The 5-line viewport is measured and matches the `count-5` code. Scrollback
|
||
beyond the viewport and any ring cap were not exercised (no pointer-input run
|
||
for the scrollbar).
|